diff --git a/.dockerignore b/.dockerignore index 7723845..de698a8 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,21 +1,101 @@ .git .github .venv +venv +.tox +.nox __pycache__ .pytest_cache .ruff_cache .mypy_cache .coverage +coverage.xml htmlcov build dist *.egg-info + +# Never send local configuration or credentials to the build daemon. +.env +**/.env +.env.* +**/.env.* +!.env.example +!**/.env.example +!.env.sample +!**/.env.sample +!.env.template +!**/.env.template +secrets +**/secrets +**/secrets/** +.secrets +**/.secrets +**/.secrets/** +credentials +**/credentials +**/credentials/** +credentials.json +service-account*.json +auth.json +.aws +.azure +.gnupg +.netrc +.npmrc +.pypirc +kubeconfig* +*.pem +*.key +*.ppk +*.p12 +*.pfx +*.jks +*.keystore +*.kdbx +*.token +*.secret +*.secrets +*.credentials +*.tfstate +*.tfstate.* +id_rsa* +id_ed25519* +id_ecdsa* +id_dsa* + +# Private measurements and generated numerical output stay outside the context. data/raw data/private +data/measurements results checkpoints -*.s?p +artifacts +*.s[0-9]*p +*.npy +*.npz +*.mat +*.csv +*.tsv +!examples/**/*.npy +!examples/**/*.npz +!examples/**/*.csv +!examples/**/*.tsv +!tests/fixtures/**/*.npy +!tests/fixtures/**/*.npz +!tests/fixtures/**/*.csv +!tests/fixtures/**/*.tsv *.h5 +*.hdf5 +*.xdmf +*.msh +*.petsc *.bp *.bp4 *.bp5 +*.pvd +*.pvtu +*.vtu +*.vtk +*.zarr +*.zarr/** diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..90707a9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,16 @@ +* text=auto + +.gitattributes text eol=lf +*.cff text eol=lf +*.json text eol=lf +*.md text eol=lf +*.py text eol=lf +*.sh text eol=lf +*.toml text eol=lf +*.yaml text eol=lf +*.yml text eol=lf +MANIFEST.in text eol=lf +SHA256SUMS text eol=lf +.dockerignore text eol=lf +.gitignore text eol=lf +Dockerfile text eol=lf diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 3f96d99..c4264a6 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -9,47 +9,419 @@ on: permissions: contents: read +env: + SCATTER3D_BASE_IMAGE_DIGEST: sha256:f7cce2a2271bf838c080751348c471064acb41fef0330e2c08178a688f71890d + SCATTER3D_SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + concurrency: - group: quality-${{ github.ref }} + group: quality-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +defaults: + run: + shell: bash + jobs: + gate-a-static: + name: Gate A / repository static checks + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ env.SCATTER3D_SOURCE_SHA }} + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.12" + - name: Install pinned metadata validator + run: python -m pip install --disable-pip-version-check cffconvert==2.0.0 + - name: Install checksummed static tools + env: + ACTIONLINT_VERSION: "1.7.12" + ACTIONLINT_SHA256: 8aca8db96f1b94770f1b0d72b6dddcb1ebb8123cb3712530b08cc387b349a3d8 + GITLEAKS_VERSION: "8.30.1" + GITLEAKS_SHA256: 551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb + run: | + set -euo pipefail + actionlint_archive="${RUNNER_TEMP}/actionlint.tar.gz" + gitleaks_archive="${RUNNER_TEMP}/gitleaks.tar.gz" + curl --proto '=https' --tlsv1.2 --fail --location --silent --show-error \ + "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \ + --output "${actionlint_archive}" + printf '%s %s\n' "${ACTIONLINT_SHA256}" "${actionlint_archive}" | sha256sum --check + tar --extract --gzip --file "${actionlint_archive}" --directory "${RUNNER_TEMP}" actionlint + curl --proto '=https' --tlsv1.2 --fail --location --silent --show-error \ + "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ + --output "${gitleaks_archive}" + printf '%s %s\n' "${GITLEAKS_SHA256}" "${gitleaks_archive}" | sha256sum --check + tar --extract --gzip --file "${gitleaks_archive}" --directory "${RUNNER_TEMP}" gitleaks + - name: Validate GitHub Actions workflows + run: | + "${RUNNER_TEMP}/actionlint" -color + - name: Validate Compose and Bash syntax + run: | + docker compose --file docker/compose.yaml config --quiet + bash -n examples/run_container_verification.sh + - name: Validate citation metadata + run: cffconvert --validate + - name: Verify public evidence manifests and status contract + run: | + set -euo pipefail + (cd docs/evidence && sha256sum --check SHA256SUMS) + python - <<'PY' + from __future__ import annotations + + import json + import re + from pathlib import Path + + evidence = Path("docs/evidence") + manifest_digests: dict[str, str] = {} + for line in (evidence / "SHA256SUMS").read_text(encoding="utf-8").splitlines(): + digest, separator, name = line.partition(" ") + if ( + len(digest) != 64 + or any(character not in "0123456789abcdef" for character in digest) + or separator != " " + or not name + or Path(name).name != name + ): + raise SystemExit("docs/evidence/SHA256SUMS has an invalid entry") + if name in manifest_digests: + raise SystemExit("docs/evidence/SHA256SUMS has a duplicate entry") + manifest_digests[name] = digest + + json_names = {path.name for path in evidence.glob("*.json") if path.is_file()} + if set(manifest_digests) != json_names: + raise SystemExit( + "docs/evidence/SHA256SUMS must cover every and only public JSON artifact" + ) + + report = json.loads( + (evidence / "campaign-03-preparation-failure.json").read_text(encoding="utf-8") + ) + expected_top_keys = { + "attempt", + "cleanup", + "control", + "failure", + "gates", + "private_archive_hashes", + "runner_contract", + "schema", + "solver_attempts", + "source", + "status", + } + if set(report) != expected_top_keys: + raise SystemExit("campaign-03 evidence has unexpected top-level fields") + + required_scalars = { + "attempt": "campaign-03", + "schema": "scatter3d.evidence.remote_preparation_failure/v1", + "status": "FAILED", + } + for key, expected in required_scalars.items(): + if report.get(key) != expected: + raise SystemExit(f"campaign-03 evidence has invalid {key}") + solver_attempts = report.get("solver_attempts") + if type(solver_attempts) is not int or solver_attempts != 0: + raise SystemExit("campaign-03 solver_attempts must be integer zero") + + expected_maps = { + "cleanup": { + "ephemeral_ssh_key": "PASSED", + "firewall": "PASSED", + "independent_absence_check": "PASSED", + "primary_ip": "PASSED", + "server": "PASSED", + }, + "control": { + "build_id": "scatter3d-provider-control-v2.3-20260713-04", + "controller_sha256": "2ef0cb8cf036268b6e2e05cb006511f05de609389253f60ebcc08af197d6c8c4", + "preparation_script_sha256": "c98f62f8db0c6ee410b8b0e003af96be333c3d72c20514ab5c7345f3c975f521", + "transport_sha256": "41d5761dd4e4258ed6a53452f2094363bc83177361bdf3516e12f1bad10154fc", + }, + "failure": { + "message": "df: options -P and --output are mutually exclusive", + "phase": "remote_preparation", + "status": "FAILED", + }, + "gates": { + "bootstrap": "PASSED", + "image_build": "NOT RUN", + "immutable_registration": "NOT RUN", + "lifecycle_canary": "NOT RUN", + "p3_pmg_scaling_sweep": "NOT RUN", + "runner_attestation": "PASSED", + "source_checkout": "NOT RUN", + "wrong_dof_canary": "NOT RUN", + }, + "private_archive_hashes": { + "campaign_manifest_sha256": "77d6a44e09e0876500849c507122d3b7f926b285c235affc52f79013b6383e42", + "ephemeral_ssh_state_sha256": "1ec325d64a1b277d659cbf23a448248367929f6a254ea6b94170effe18125645", + "partial_preparation_tar_sha256": "8f4d3ded40f315df00a6b06f56fab8e427f820ad50c13f4a389aa60288bb44c1", + "provider_deleted_state_sha256": "0f587a296cf35c16074c6d802e8884967d531dd25ae178a0f65e17e3c086d906", + }, + "runner_contract": {"location": "fsn1", "server_type": "cpx62"}, + "source": { + "ci_run": 29222225396, + "ci_status": "PASSED", + "commit": "5393e4494a5f63bdca24defba18eb7af7ffa5bf4", + }, + } + for key, expected in expected_maps.items(): + if report.get(key) != expected: + raise SystemExit(f"campaign-03 evidence has invalid {key} map") + + scaling_text = Path("docs/SCALING_EVIDENCE.md").read_text(encoding="utf-8") + digest_match = re.search( + r"The public JSON has SHA-256\s+`([0-9a-f]{64})`", + scaling_text, + ) + expected_digest = manifest_digests["campaign-03-preparation-failure.json"] + if digest_match is None or digest_match.group(1) != expected_digest: + raise SystemExit("scaling evidence cites a stale public JSON digest") + private_digest_match = re.search( + r"canonical private archive manifest has SHA-256\s+`([0-9a-f]{64})`", + scaling_text, + ) + expected_private_digest = report["private_archive_hashes"][ + "campaign_manifest_sha256" + ] + if ( + private_digest_match is None + or private_digest_match.group(1) != expected_private_digest + ): + raise SystemExit("scaling evidence cites a stale private archive digest") + print("public evidence manifest and status contract verify") + PY + - name: Validate repository-local Markdown links + run: | + python - <<'PY' + from __future__ import annotations + + import re + from pathlib import Path + from urllib.parse import unquote, urlsplit + + root = Path.cwd().resolve() + ignored_parts = { + ".git", + ".mypy_cache", + ".nox", + ".pytest_cache", + ".ruff_cache", + ".tox", + ".venv", + "build", + "dist", + "htmlcov", + } + link_pattern = re.compile(r"(?]+>|[^\s)]+)") + failures: list[str] = [] + + for markdown_path in sorted(root.rglob("*.md")): + if ignored_parts.intersection(markdown_path.relative_to(root).parts): + continue + text = markdown_path.read_text(encoding="utf-8") + for match in link_pattern.finditer(text): + raw_target = match.group(1).strip("<>") + parsed = urlsplit(raw_target) + if parsed.scheme or parsed.netloc or raw_target.startswith("#"): + continue + relative_target = unquote(parsed.path) + if not relative_target: + continue + resolved = (markdown_path.parent / relative_target).resolve() + try: + resolved.relative_to(root) + except ValueError: + failures.append( + f"{markdown_path.relative_to(root)}: target leaves repository: {raw_target}" + ) + continue + if not resolved.exists(): + failures.append( + f"{markdown_path.relative_to(root)}: missing target: {raw_target}" + ) + + if failures: + raise SystemExit("\n".join(failures)) + print("repository-local Markdown links resolve") + PY + - name: Scan committed history and working tree for secrets + run: | + "${RUNNER_TEMP}/gitleaks" git --no-banner --no-color --redact . + "${RUNNER_TEMP}/gitleaks" dir --no-banner --no-color --redact . + pure-python: name: Pure Python / ${{ matrix.python-version }} - runs-on: ubuntu-latest + needs: gate-a-static + runs-on: ubuntu-24.04 + timeout-minutes: 20 strategy: fail-fast: false matrix: - python-version: ["3.11", "3.12"] + python-version: ["3.11", "3.12", "3.13", "3.14"] steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + ref: ${{ env.SCATTER3D_SOURCE_SHA }} + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: ${{ matrix.python-version }} cache: pip + cache-dependency-path: pyproject.toml - name: Install - run: python -m pip install --upgrade pip && python -m pip install -e ".[test]" + run: | + python -m pip install --disable-pip-version-check -e ".[test]" + python -m pip check - name: Lint run: python -m ruff check . - name: Test pure paths - run: python -m pytest -m "not heavy and not mpi" --cov=scatter3d --cov-report=term-missing - - name: Build distributions - run: python -m build + run: >- + python -m pytest -p no:cacheprovider -W error + -m "not heavy and not mpi" + --cov=scatter3d --cov-report=term-missing + + package-distributions: + name: Gate A / build and install distributions + needs: gate-a-static + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + ref: ${{ env.SCATTER3D_SOURCE_SHA }} + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: pyproject.toml + - name: Build wheel and source distribution + run: | + python -m pip install --disable-pip-version-check build==1.2.2.post1 + python -m build + - name: Verify source-distribution experiment contract + run: | + python - <<'PY' + from pathlib import Path, PurePosixPath + import tarfile + + archives = list(Path("dist").glob("*.tar.gz")) + if len(archives) != 1: + raise SystemExit(f"expected exactly one source distribution, found {archives}") + expected_root = archives[0].name.removesuffix(".tar.gz") + + required = { + PurePosixPath("docs/evidence/SHA256SUMS"), + PurePosixPath("docs/evidence/campaign-03-preparation-failure.json"), + PurePosixPath("validation/register_scaling_sweep.py"), + PurePosixPath("validation/remote_capacity_preflight.py"), + PurePosixPath("validation/run_registered_scaling_sweep.py"), + PurePosixPath("validation/scaling_sweep_v1.json"), + } + with tarfile.open(archives[0], "r:gz") as archive: + entries = {} + for member in archive.getmembers(): + path = PurePosixPath(member.name) + if path.is_absolute() or ".." in path.parts: + raise SystemExit( + f"source distribution contains unsafe path: {member.name!r}" + ) + if not path.parts or path.parts[0] != expected_root: + raise SystemExit( + "source distribution does not have exactly the expected root: " + f"{member.name!r} versus {expected_root!r}" + ) + if len(path.parts) > 1: + relative = PurePosixPath(*path.parts[1:]) + entries.setdefault(relative, []).append(member) + invalid = sorted( + path + for path in required + if len(entries.get(path, [])) != 1 or not entries[path][0].isfile() + ) + if invalid: + raise SystemExit( + "source distribution must contain each required experiment/evidence file " + f"exactly once as a regular file: {invalid}" + ) + mismatched = [] + for path in sorted(required): + archived = archive.extractfile(entries[path][0]) + if archived is None or archived.read() != Path(*path.parts).read_bytes(): + mismatched.append(path) + if mismatched: + raise SystemExit( + "source distribution experiment/evidence files differ from the checkout: " + f"{mismatched}" + ) + print("source distribution retains the scaling contract and cited evidence") + PY + - name: Verify wheel and source-distribution installs + run: | + set -euo pipefail + python -m venv "${RUNNER_TEMP}/wheel-venv" + "${RUNNER_TEMP}/wheel-venv/bin/python" -m pip install --disable-pip-version-check dist/*.whl + "${RUNNER_TEMP}/wheel-venv/bin/python" -m pip check + "${RUNNER_TEMP}/wheel-venv/bin/scatter3d" --help + python -m venv "${RUNNER_TEMP}/sdist-venv" + "${RUNNER_TEMP}/sdist-venv/bin/python" -m pip install --disable-pip-version-check dist/*.tar.gz + "${RUNNER_TEMP}/sdist-venv/bin/python" -m pip check + "${RUNNER_TEMP}/sdist-venv/bin/scatter3d" --help + - name: Archive distributions + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: scatter3d-python-distributions + path: dist/* + if-no-files-found: error + retention-days: 14 + compression-level: 0 heavy-dolfinx: name: Complex DOLFINx heavy tests - runs-on: ubuntu-latest + needs: [pure-python, package-distributions] + runs-on: ubuntu-24.04 + timeout-minutes: 45 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + ref: ${{ env.SCATTER3D_SOURCE_SHA }} - name: Build pinned image - run: docker build -f docker/Dockerfile -t scatter3d-codex:ci . + run: >- + docker build --pull=false + --build-arg SCATTER3D_GIT_COMMIT="${SCATTER3D_SOURCE_SHA}" + --build-arg SCATTER3D_GIT_DIRTY=false + -f docker/Dockerfile -t scatter3d-codex:ci . + - name: Record local project image identity + run: | + project_image_id="$(docker image inspect --format '{{.Id}}' scatter3d-codex:ci)" + if [[ ! "${project_image_id}" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "invalid local project image ID: ${project_image_id}" >&2 + exit 1 + fi + echo "SCATTER3D_PROJECT_IMAGE_ID=${project_image_id}" >> "${GITHUB_ENV}" - name: Run heavy tests and reject skips run: | - docker run --rm scatter3d-codex:ci bash -euc ' - python3 -m pytest -m "heavy and not mpi" -ra --junitxml=/tmp/heavy.xml + mkdir -p "${RUNNER_TEMP}/scatter3d-heavy" + chmod 0777 "${RUNNER_TEMP}/scatter3d-heavy" + set +e + docker run --rm \ + --env SCATTER3D_PROJECT_IMAGE_ID \ + --env SCATTER3D_BASE_IMAGE_DIGEST \ + --volume "${RUNNER_TEMP}/scatter3d-heavy:/artifacts" \ + scatter3d-codex:ci bash -euc ' + python3 -m pytest -p no:cacheprovider -W error -m "heavy and not mpi" -ra --junitxml=/artifacts/heavy-junit.xml python3 - <<"PY" import xml.etree.ElementTree as ET - root = ET.parse("/tmp/heavy.xml").getroot() + root = ET.parse("/artifacts/heavy-junit.xml").getroot() suites = [root] if root.tag == "testsuite" else list(root.findall("testsuite")) tests = sum(int(s.get("tests", 0)) for s in suites) skipped = sum(int(s.get("skipped", 0)) for s in suites) @@ -57,25 +429,100 @@ jobs: raise SystemExit(f"heavy verification invalid: tests={tests}, skipped={skipped}") print(f"heavy verification complete: tests={tests}, skipped={skipped}") PY - ' + python3 validation/manufactured_hcurl.py \ + --degrees 1 2 3 \ + --subdivisions 3 4 6 \ + --output /artifacts/manufactured-hcurl.json + python3 validation/fem_smoke.py \ + --solver direct \ + --degree 3 \ + --subdivisions 2 \ + --frequencies-hz 1.0e8 \ + --output /artifacts/fem-smoke-direct-p3.json + python3 validation/fem_smoke.py \ + --solver iterative \ + --iterative-hierarchy p-multigrid \ + --p-multigrid-coarse-degree 1 \ + --preconditioner-absorption-shift 0.5 \ + --iterative-local-pc lu \ + --degree 3 \ + --subdivisions 2 \ + --frequencies-hz 1.0e8 \ + --output /artifacts/fem-smoke-pmg-serial-p3-p1.json + cp /opt/scatter3d/runtime-metadata.json /artifacts/runtime-metadata.json + ' 2>&1 | tee "${RUNNER_TEMP}/scatter3d-heavy/execution.log" + pipeline_status=("${PIPESTATUS[@]}") + docker_status="${pipeline_status[0]}" + tee_status="${pipeline_status[1]}" + set -e + printf 'docker=%s\ntee=%s\n' "${docker_status}" "${tee_status}" \ + > "${RUNNER_TEMP}/scatter3d-heavy/pipeline-exit-codes.txt" + status="${docker_status}" + if [[ "${status}" -eq 0 ]]; then status="${tee_status}"; fi + printf '%s\n' "${status}" > "${RUNNER_TEMP}/scatter3d-heavy/exit-code.txt" + exit "${status}" + - name: Finalize heavy evidence hashes + if: always() + run: | + cd "${RUNNER_TEMP}/scatter3d-heavy" + unreadable="$(find . -maxdepth 1 -type f ! -perm -004 -print)" + test -z "${unreadable}" + manifest="${RUNNER_TEMP}/scatter3d-heavy-SHA256SUMS.tmp" + find . -maxdepth 1 -type f ! -name SHA256SUMS -printf '%f\0' \ + | sort -z | xargs -0 -r sha256sum > "${manifest}" + mv "${manifest}" SHA256SUMS + - name: Archive heavy verification evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: scatter3d-heavy-verification-${{ env.SCATTER3D_SOURCE_SHA }} + path: ${{ runner.temp }}/scatter3d-heavy/* + if-no-files-found: error + retention-days: 30 mpi: name: Two-rank MPI tests - runs-on: ubuntu-latest + needs: [pure-python, package-distributions] + runs-on: ubuntu-24.04 + timeout-minutes: 45 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + ref: ${{ env.SCATTER3D_SOURCE_SHA }} - name: Build pinned image - run: docker build -f docker/Dockerfile -t scatter3d-codex:ci . + run: >- + docker build --pull=false + --build-arg SCATTER3D_GIT_COMMIT="${SCATTER3D_SOURCE_SHA}" + --build-arg SCATTER3D_GIT_DIRTY=false + -f docker/Dockerfile -t scatter3d-codex:ci . + - name: Record local project image identity + run: | + project_image_id="$(docker image inspect --format '{{.Id}}' scatter3d-codex:ci)" + if [[ ! "${project_image_id}" =~ ^sha256:[0-9a-f]{64}$ ]]; then + echo "invalid local project image ID: ${project_image_id}" >&2 + exit 1 + fi + echo "SCATTER3D_PROJECT_IMAGE_ID=${project_image_id}" >> "${GITHUB_ENV}" - name: Run on two ranks and reject skips run: | - docker run --rm --ipc=host scatter3d-codex:ci bash -euc ' + mkdir -p "${RUNNER_TEMP}/scatter3d-mpi" + chmod 0777 "${RUNNER_TEMP}/scatter3d-mpi" + set +e + docker run --rm --ipc=host \ + --env SCATTER3D_PROJECT_IMAGE_ID \ + --env SCATTER3D_BASE_IMAGE_DIGEST \ + --volume "${RUNNER_TEMP}/scatter3d-mpi:/artifacts" \ + scatter3d-codex:ci bash -euc ' mpirun -n 2 sh -euc '\'' - python3 -m pytest -m mpi -ra --junitxml=/tmp/mpi-${OMPI_COMM_WORLD_RANK}.xml + rank="${PMI_RANK:-${OMPI_COMM_WORLD_RANK:-}}" + test -n "${rank}" + python3 -m pytest -p no:cacheprovider -W error -m mpi -ra --junitxml="/artifacts/mpi-${rank}-junit.xml" '\'' python3 - <<"PY" import glob import xml.etree.ElementTree as ET - files = glob.glob("/tmp/mpi-*.xml") + files = glob.glob("/artifacts/mpi-*-junit.xml") if len(files) != 2: raise SystemExit(f"expected two MPI reports, found {files}") for path in files: @@ -87,4 +534,49 @@ jobs: raise SystemExit(f"MPI verification invalid in {path}: tests={tests}, skipped={skipped}") print("MPI verification complete on two ranks with no skips") PY - ' + mpirun -n 2 python3 validation/fem_smoke.py \ + --solver iterative \ + --degree 1 \ + --subdivisions 2 \ + --frequencies-hz 1.0e8 \ + --output /artifacts/fem-smoke-iterative-mpi2.json + mpirun -n 2 python3 validation/fem_smoke.py \ + --solver iterative \ + --iterative-hierarchy p-multigrid \ + --p-multigrid-coarse-degree 1 \ + --preconditioner-absorption-shift 0.5 \ + --iterative-local-pc lu \ + --degree 3 \ + --subdivisions 2 \ + --frequencies-hz 1.0e8 \ + --output /artifacts/fem-smoke-pmg-mpi2-p3-p1.json + cp /opt/scatter3d/runtime-metadata.json /artifacts/runtime-metadata.json + ' 2>&1 | tee "${RUNNER_TEMP}/scatter3d-mpi/execution.log" + pipeline_status=("${PIPESTATUS[@]}") + docker_status="${pipeline_status[0]}" + tee_status="${pipeline_status[1]}" + set -e + printf 'docker=%s\ntee=%s\n' "${docker_status}" "${tee_status}" \ + > "${RUNNER_TEMP}/scatter3d-mpi/pipeline-exit-codes.txt" + status="${docker_status}" + if [[ "${status}" -eq 0 ]]; then status="${tee_status}"; fi + printf '%s\n' "${status}" > "${RUNNER_TEMP}/scatter3d-mpi/exit-code.txt" + exit "${status}" + - name: Finalize MPI evidence hashes + if: always() + run: | + cd "${RUNNER_TEMP}/scatter3d-mpi" + unreadable="$(find . -maxdepth 1 -type f ! -perm -004 -print)" + test -z "${unreadable}" + manifest="${RUNNER_TEMP}/scatter3d-mpi-SHA256SUMS.tmp" + find . -maxdepth 1 -type f ! -name SHA256SUMS -printf '%f\0' \ + | sort -z | xargs -0 -r sha256sum > "${manifest}" + mv "${manifest}" SHA256SUMS + - name: Archive MPI verification evidence + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: scatter3d-mpi-verification-${{ env.SCATTER3D_SOURCE_SHA }} + path: ${{ runner.temp }}/scatter3d-mpi/* + if-no-files-found: error + retention-days: 30 diff --git a/.gitignore b/.gitignore index 511163b..ee79bf9 100644 --- a/.gitignore +++ b/.gitignore @@ -17,23 +17,80 @@ build/ dist/ *.egg-info/ +# Local configuration and credentials (example templates remain publishable) +.env +.env.* +!.env.example +!.env.sample +!.env.template +secrets/ +.secrets/ +credentials/ +credentials.json +service-account*.json +auth.json +.aws/ +.azure/ +.gnupg/ +.netrc +.npmrc +.pypirc +kubeconfig* +*.pem +*.key +*.ppk +*.p12 +*.pfx +*.jks +*.keystore +*.kdbx +*.token +*.secret +*.secrets +*.credentials +*.tfstate +*.tfstate.* +id_rsa* +id_ed25519* +id_ecdsa* +id_dsa* + # FEM and MPI outputs *.bp/ *.bp4/ *.bp5/ *.xdmf *.h5 +*.hdf5 *.msh *.petsc +*.pvd +*.pvtu +*.vtu +*.vtk +*.zarr/ checkpoints/ results/ +artifacts/ # Measurement data are intentionally never committed by default data/raw/ data/private/ +data/measurements/ *.s[0-9]*p +*.npy *.npz +*.mat +*.csv +*.tsv +!examples/**/*.npy !examples/**/*.npz +!examples/**/*.csv +!examples/**/*.tsv +!tests/fixtures/**/*.npy +!tests/fixtures/**/*.npz +!tests/fixtures/**/*.csv +!tests/fixtures/**/*.tsv # Editors and operating systems .idea/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 3476824..950f8ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,18 @@ All notable changes will be documented here. The format follows ## [Unreleased] +This development version has not been released. The entries below describe the +current repository state, not completed hardware or scaling acceptance. + ### Added +- Documented PETSc 3.25 mixed-precision MUMPS and MUMPS 5.9 adaptive BLR as + separate **NOT RUN** research candidates, with host-high-water evidence still + required for any at-most-50% memory claim. +- Added a shell-independent `statvfs` disk-capacity preflight for use by the + next disposable-runner campaign. Campaign-03 **FAILED** before either canary, + registration, or a FEM solve; its evidence and verified provider cleanup + remain archived. - Clean-room Apache-2.0 project identity. - Canonical scattering-data contract with explicit receiver/source ordering. - Reference-only alignment, differential measurement, repeat-noise estimation, @@ -16,12 +26,84 @@ All notable changes will be documented here. The format follows same-matrix multi-right-hand-side reuse, diagnostics, and checkpoint identity. - Verification-first CLI, measurement runbook, convergence protocol, pinned complex DOLFINx container, and separate pure/heavy/MPI CI gates. +- Distributed iterative validation controls for preconditioning side, GMRES + restart, ASM overlap, local ILU/LU choice, true-residual gates, and + same-problem memory comparison. +- A separately assembled absorption-shifted iterative preconditioning operator + `P`, while preserving the physical Maxwell operator `A` and right-hand sides. +- A genuine two-level p=3-to-p=1 Nedelec p-multigrid path with a PEC-masked + interpolation operator, one-step ASM/MUMPS fine smoothing, and a global + p=1 MUMPS coarse correction. +- Validation schema `scatter3d.validation.fem_smoke/v2` with source, command, + image, runtime, cgroup, physical-problem, requested/effective solver, and + preconditioner provenance plus atomic no-clobber output. +- Immutable eight-run scaling-sweep registration and an independent-per-run Docker + executor with exact physical/DoF/image/runtime binding, registered wall time, + no-swap enforcement, host cgroup-v2 peak capture, and write-once manifests. + +### Changed + +- Expanded the declared pure-Python CI matrix to Python 3.11 through 3.14 while + retaining an open-ended `>=3.11` package requirement. +- Added reproducibility material to the source distribution and archived built + wheel/source artifacts in CI. +- Added static workflow, Compose, Bash, citation, local-link, and secret checks + ahead of numerical CI gates. +- Kept prefixed PETSc options installed through `KSPSetUp` so nested ASM + subdomain KSP/PC objects consume their requested configuration before the + temporary options are removed. +- Iterative solves now call `KSPSetOperators(A, P)`, record fine/preconditioner + matrix metrics separately, and reuse `A` as `P` when the shift is zero. +- Effective PETSc diagnostics now fail closed on typed top-level mismatches and + retain live nested ASM components, parsed ASM type/overlap, and the raw ASCII + KSP view. + +### Fixed + +- Corrected a PETSc option-lifecycle bug in which a requested ASM subdomain LU + remained the default ILU because nested options were removed before setup. + A heavy regression now proves nested setup consumes the requested options. +- Hardened registered scaling execution so completed FEM PASSED/FAILED artifacts + with a published `fem-smoke.json` are checked against numerical, hierarchy, + operator, counter, cgroup, and exit-code evidence. Preflight and missing-artifact + failures remain explicit. Docker cleanup is attempt-bound, refuses foreign name + conflicts, and observes the full post-timeout window before claiming absence. +- Included the immutable scaling-sweep JSON specification in source distributions + and added a package gate that rejects an sdist missing the registration script, + executor, or registered experiment contract. +- Stopped multi-entry scaling execution after the first non-passing result so a + `BLOCKED` or unsafe `FAILED` entry cannot silently launch the next registered + solve; a reviewed numerical failure can resume only through an explicit + unstarted `--run-id`. + +### Security + +- Excluded dotenv files, credential/key formats, private measurements, and + generated numerical artifacts from Git and Docker contexts while retaining + explicitly placed example fixtures. ### Validation boundary - Pure numerical and schema paths are covered by automated tests. - Heavy FEM and MPI paths are required to execute in their dedicated CI jobs. -- No real POM/PLA VNA dataset is distributed, so a successful real-object image - is **not** claimed. +- Repository/static/package, CPython 3.11–3.14, heavy complex DOLFINx, and + two-rank MPI CI gates **PASSED** at `c3c1ded`. +- A p=3, 86,103-global-complex-DoF iterative solve with corrected local MUMPS LU + **PASSED** for two right-hand sides. +- The identical-problem peak-memory-at-most-50%-of-direct gate **FAILED** with a + summed rank peak-RSS ratio of `0.8263214111`. +- The p=3, 470,928-global-complex-DoF iterative rung **FAILED** its registered + true-residual gate after both right-hand sides reached 1,000 iterations. +- Convergence at 3,000,000 or more global complex DoFs is **NOT RUN**. +- Small serial and two-rank p=3-to-p=1 p-multigrid correctness artifacts + **PASSED** at `bee9e9d`; the registered 86k/471k absorption-shift sweep is + **NOT RUN**. This does not alter the historical `c3c1ded` scaling evidence. +- Campaign-03 remote preparation **FAILED** before source checkout on an + incompatible GNU `df` option combination. Attestation and bootstrap + **PASSED**; both canaries, registration, and every solver run are **NOT RUN**. + All attempt-owned provider resources and the ephemeral key were deleted. +- Real POM/PLA VNA reconstruction is **BLOCKED** because no accepted raw + measurement/control bundle has been supplied; no successful real-object image + is claimed. [Unreleased]: https://github.com/HunterSpence/Scatter3D-Codex/commits/main diff --git a/CITATION.cff b/CITATION.cff index 032bbe7..2b77215 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -8,8 +8,6 @@ authors: repository-code: "https://github.com/HunterSpence/Scatter3D-Codex" url: "https://github.com/HunterSpence/Scatter3D-Codex" license: Apache-2.0 -version: 0.1.0 -date-released: 2026-07-12 keywords: - microwave imaging - inverse scattering diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0f0127c..10a0745 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -17,13 +17,16 @@ python -m ruff check . The DOLFINx/PETSc stack is tested in the pinned container: ```bash -docker build -f docker/Dockerfile -t scatter3d-codex:test . +docker build --pull=false -f docker/Dockerfile -t scatter3d-codex:test . docker run --rm scatter3d-codex:test \ - python -m pytest -m heavy -ra + python3 -m pytest -p no:cacheprovider -W error -m "heavy and not mpi" -ra docker run --rm --ipc=host scatter3d-codex:test \ - mpirun --allow-run-as-root -n 2 python -m pytest -m mpi -ra + mpirun -n 2 python3 -m pytest -p no:cacheprovider -W error -m mpi -ra ``` +Run `examples/run_container_verification.sh` for the full helper that also +rejects zero-test and skipped-test heavy/MPI reports. + ## Pull-request requirements 1. Explain the physical or numerical contract being changed. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..34edb16 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,10 @@ +include README.md LICENSE CITATION.cff CHANGELOG.md CONTRIBUTING.md SECURITY.md +include pyproject.toml .dockerignore .gitignore .gitattributes +include .github/workflows/quality.yml + +recursive-include docs *.md *.json SHA256SUMS +recursive-include docker Dockerfile *.yaml *.txt *.md +recursive-include examples *.py *.sh +recursive-include tests *.py +recursive-include validation *.py +include validation/scaling_sweep_v1.json diff --git a/README.md b/README.md index b45c767..0770a83 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@ # Scatter3D-Codex -[![quality](https://github.com/HunterSpence/Scatter3D-Codex/actions/workflows/quality.yml/badge.svg)](https://github.com/HunterSpence/Scatter3D-Codex/actions/workflows/quality.yml) [![license](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) Scatter3D-Codex is a clean-room, verification-first framework for differential @@ -16,17 +15,45 @@ from that repository was copied. | Capability | Evidence required by this repository | Claim | |---|---|---| -| Data order, hashes, reference/DUT subtraction | Pure unit and end-to-end synthetic tests | Implemented and testable | -| Repeat-floor diagnostics and complex TSVD | Pure deterministic tests | Implemented and testable | -| Maxwell forms, PML, port normalization, checkpoints | Digest-pinned complex DOLFINx heavy tests | Must pass the heavy CI job | -| Two-rank operation | Dedicated MPI tests with zero permitted skips | Must pass the MPI CI job | -| Real POM/PLA object imaging | Archived VNA repeats, nulls, known target, materials, and acceptance report | **Not yet demonstrated** | -| Production-scale convergence | Registered mesh/PML/quadrature sweep on the actual geometry | **Not yet demonstrated** | +| Data order, hashes, reference/DUT subtraction | Pure unit and end-to-end synthetic tests on the exact revision | **PASSED** on CPython 3.11–3.14 at `c3c1ded` | +| Repeat-floor diagnostics and complex TSVD | Pure deterministic tests on the exact revision | **PASSED** at `c3c1ded` | +| Manufactured H(curl), Nedelec p=1/2/3 | Three mesh levels per degree in the pinned complex runtime | **PASSED** at `ad9a43b`; archived JSON records orders and residuals | +| Matched TEM boundary and electric-mode power normalization | Digest-pinned complex DOLFINx tests | Software/runtime checks **PASSED**; calibrated incident/outgoing S-parameter extraction and an independent port benchmark are **NOT RUN** | +| Two-rank operation | Dedicated MPI test and iterative repeated-RHS smoke solve with zero permitted skips | **PASSED** at `c3c1ded` for the small 98-DoF correctness case; this is not scaling evidence | +| Two-level p=3-to-p=1 p-multigrid correctness | Shifted-Pmat serial and two-rank MPI solves with live hierarchy/operator checks | **PASSED** at `bee9e9d`: 1,158 fine DoFs, 98 coarse DoFs, two RHS; this is not scaling evidence | +| Registered p-multigrid scaling sweep | Both canaries, immutable registration, and all eight registered runs | **NOT RUN**; campaign-03 preparation **FAILED** before either canary, registration, or a solver launch | +| p=3 iterative solve at 86,103 global complex DoFs | Two RHS, positive PETSc reasons, and true relative residual at most `1e-7` | **PASSED** at `c3c1ded` with right FGMRES, ASM overlap 1, and local MUMPS LU | +| p=3 iterative solve at 470,928 global complex DoFs | Same two-RHS residual gate | **FAILED** at `c3c1ded`; both RHS reached 1,000 iterations and residuals were `2.50e-7` and `5.51e-6` | +| Real POM/PLA object imaging | Archived VNA repeats, nulls, known target, materials, and acceptance report | **BLOCKED** because no accepted raw measurement bundle has been supplied | +| At least 3,000,000 global complex DoFs | Archived distributed convergence artifact | **NOT RUN** | +| Peak memory at most 50% of direct | Instrumented identical-problem direct/iterative comparison | **FAILED** at 86,103 DoFs: summed rank peak RSS ratio `0.8263214111` | Passing software tests proves the software checks they exercise. It does not prove that a particular fixture, calibration, material model, or linearized inverse problem contains enough information to image a real object. +The latest identified automated gates are retained by [GitHub Actions run +29214242189](https://github.com/HunterSpence/Scatter3D-Codex/actions/runs/29214242189) +at `d5fe814`. Earlier manufactured-solution history is also retained by [run +29206335149](https://github.com/HunterSpence/Scatter3D-Codex/actions/runs/29206335149). +The larger remote solver evidence, including failures and SHA-256 hashes, is +catalogued in [Scaling evidence](docs/SCALING_EVIDENCE.md). + +The current source separates the physical Maxwell matrix `A` from an optional +absorption-shifted preconditioning matrix `P`, calls `KSPSetOperators(A, P)`, +and provides an assembled p=3-to-p=1 two-level correction. Exact-revision +serial and two-rank correctness artifacts **PASSED** at 1,158 fine DoFs. The +registered 86,103- and 470,928-DoF p-multigrid shift sweep remains **NOT RUN**, +so no mesh-scalability claim follows from the small correctness cases. + +Campaign-03 disposable-runner preparation at `5393e44` **FAILED** before +source checkout because its disk-capacity preflight combined incompatible GNU +`df` options. Runner attestation and bootstrap **PASSED**; the image build, +both canaries, immutable registration, and every solver run were **NOT RUN**. +All attempt-owned provider resources and the ephemeral key were deleted and +independently verified absent. The sanitized record is +[`campaign-03-preparation-failure.json`](docs/evidence/campaign-03-preparation-failure.json). + ## Why this design Synthetic-to-synthetic reconstruction can succeed while measured data fail @@ -39,7 +66,9 @@ inputs**, not as an afterthought applied after a noisy image appears. ## Quick start: pure Python canary -Python 3.11 or 3.12 is supported for the measurement and inverse layers. +Python 3.11 through 3.14 are the configured CI targets for the measurement and +inverse layers. A version is supported only when the exact-revision CI job for +that version passes. ```bash python -m venv .venv @@ -81,6 +110,55 @@ docker run --rm --ipc=host scatter3d-codex:local \ CI rejects a heavy or MPI job that collects no tests or reports any skip. A green pure-Python job cannot conceal a missing FEM runtime. +### Immutable remote scaling sweep + +[`validation/scaling_sweep_v1.json`](validation/scaling_sweep_v1.json) fixes the +eight-run p=3-to-p=1 sweep: two mesh/MPI rungs, four absorption shifts, two port +right-hand sides, exact DoF gates, a 28 GiB no-swap cgroup, and a 10,800-second +wall-time cap per run. Create the registration outside the clean source tree +before the first solve: + +```bash +test -z "$(git status --porcelain --untracked-files=normal)" +SOURCE_COMMIT=$(git rev-parse HEAD) +mkdir -p /opt/scatter3d-evidence +docker build --file docker/Dockerfile \ + --build-arg SCATTER3D_GIT_COMMIT="$SOURCE_COMMIT" \ + --build-arg SCATTER3D_GIT_DIRTY=false \ + --tag scatter3d-codex:bench . +IMAGE_ID=$(docker image inspect --format '{{.Id}}' scatter3d-codex:bench) +docker run --rm --entrypoint cat "$IMAGE_ID" \ + /opt/scatter3d/runtime-metadata.json > /opt/scatter3d-evidence/runtime.json +python3 validation/register_scaling_sweep.py \ + --repository . \ + --project-image-kind local_image_id \ + --project-image-identity "$IMAGE_ID" \ + --base-image-digest sha256:f7cce2a2271bf838c080751348c471064acb41fef0330e2c08178a688f71890d \ + --base-runtime-metadata /opt/scatter3d-evidence/runtime.json \ + --output /opt/scatter3d-evidence/registration.json +``` + +Execute one immutable entry at a time without reusing any existing run +directory. Any non-passing result stops automatic execution. A remaining +unstarted entry may proceed only after the completed evidence is mirrored and +an explicit review determines that the failure was numerical and the runner, +instrumentation, and continuation contract remain safe: + +```bash +python3 validation/run_registered_scaling_sweep.py \ + --registration /opt/scatter3d-evidence/registration.json \ + --repository . \ + --image "$IMAGE_ID" \ + --output-parent /opt/scatter3d-evidence \ + --run-id p3-n9-mpi4-shift-0p50 +``` + +The executor validates source/image/runtime/physical identities, captures the +host cgroup-v2 peak before container removal, preserves stdout/stderr and honest +`FAILED` artifacts, publishes `exit-code.json` plus `SHA256SUMS`, and refuses to +clobber any existing run directory. `--timeout-seconds`, when supplied, must +equal the registered wall-time cap. + ## Core contracts - A scattering sample is `S[angle, frequency, receiver, source]` with @@ -95,8 +173,31 @@ pure-Python job cannot conceal a missing FEM runtime. an explicit label. - Complex values remain complex through noise estimation, whitening, and inversion. -- A FEM solve is accepted only with a positive PETSc convergence reason and a - reported true relative residual. +- Measurement and sensitivity archives use exact `complex128`, `float64`, + Unicode, and `int64` schema dtypes; the loader does not silently cast them. +- Diagnosis reports both aggregate and per-frequency/per-receiver/per-source + repeat-floor metrics. +- Reconstruction artifacts retain the complete compact SVD spectrum, selector + ranks and criterion values, status, thresholds, and input/artifact hashes. +- The automatic whitened discrepancy target `sqrt(rows)` is only the expected + RMS scale under the documented complex-noise convention. If no rank meets it, + the artifact is `FAILED` and the CLI exits nonzero unless the user explicitly + requests `--allow-unmet-discrepancy`. +- Existing JSON and reconstruction outputs are not overwritten by default; + `--force` is explicit and recorded in reconstruction provenance. +- FEM validation JSON uses schema `scatter3d.validation.fem_smoke/v2`, records + source, command, image, runtime, cgroup, and physical-problem identities, and + refuses to replace an existing artifact unless `--overwrite` is explicit. +- A nonzero iterative absorption shift changes only the separately assembled + preconditioning matrix `P`; the physical matrix `A`, right-hand sides, and + recomputed true residual remain unshifted. Zero shift explicitly reuses `A` + as `P` without a duplicate matrix assembly. +- Effective PETSc diagnostics are captured after setup and include the top-level + solver, side and tolerances, nested ASM solvers, parsed ASM type/overlap, and + the raw PETSc ASCII view. A typed/effective mismatch fails closed. +- A FEM linear solve is numerically accepted only with a positive PETSc + convergence reason and a reported true relative residual. This is not, by + itself, validation of a physical port or an S-parameter. See [Data schema](docs/DATA_SCHEMA.md) and [Architecture](docs/ARCHITECTURE.md) for the full contracts. @@ -129,7 +230,7 @@ src/scatter3d/ metrics.py volume-weighted image metrics provenance.py stable hashing and manifests pipeline.py checked NPZ-to-reconstruction workflow - fem/ DOLFINx/PETSc mesh, forms, PML, ports, solver, checkpoints + fem/ DOLFINx/PETSc mesh, forms, PML, port research, solver, checkpoints tests/ pure, heavy, and MPI verification examples/ deterministic software canaries docs/ experiment, convergence, schema, and evidence guides @@ -138,12 +239,14 @@ docker/ digest-pinned complex numerical runtime ## Documentation +- [Continuation handoff for the registered remote sweep](docs/CONTINUATION_HANDOFF_2026-07-13.md) - [Measurement runbook](docs/MEASUREMENT_RUNBOOK.md) - [Convergence protocol](docs/CONVERGENCE_PROTOCOL.md) - [Architecture](docs/ARCHITECTURE.md) - [Data schema](docs/DATA_SCHEMA.md) - [CLI workflows](docs/CLI.md) - [Verification status and release gate](docs/VERIFICATION.md) +- [Distributed solver scaling evidence](docs/SCALING_EVIDENCE.md) - [Primary references](docs/REFERENCES.md) ## Scope boundaries @@ -154,6 +257,12 @@ The initial inverse layer addresses the documented small-perturbation linear model; use its residual and null controls to decide when that approximation is not credible. +The historical FEM surface-current load is explicitly uncalibrated: it has no +matched termination, power-wave reference, or S-parameter meaning. The matched +single-mode TEM boundary and electric-mode power-normalization software checks +**PASSED** at `c3c1ded`, but incident/outgoing magnetic modal extraction, calibrated +S-parameters, reciprocity, and an independent port benchmark remain **NOT RUN**. + ## License and citation Code and original documentation are licensed under Apache-2.0. See [LICENSE](LICENSE) diff --git a/SECURITY.md b/SECURITY.md index 034336a..cb09670 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -3,7 +3,9 @@ ## Supported versions Security fixes are made on the default branch until the project begins publishing -stable releases. No historical release line is currently supported. +stable releases. No tagged software release or historical release line is +currently supported; package metadata in the working tree is not a release +announcement. ## Reporting a vulnerability @@ -21,4 +23,7 @@ within seven days; a repair timeline depends on severity and reproducibility. attachments. Manifests contain hashes, not secrets. - The CLI never controls a VNA or initiates RF emission. Instrument automation is intentionally out of scope for the initial release. +- CLI-generated reports and reconstruction artifacts are no-clobber by default. + Treat `--force` as explicit authorization to replace an existing local + artifact, not as an integrity or authenticity guarantee. - The container is a reproducibility environment, not a hardened sandbox. diff --git a/docker/Dockerfile b/docker/Dockerfile index 0b2f61c..36713ad 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,10 +1,16 @@ # The digest makes the numerical base reproducible even if the v0.10.0 tag moves. FROM dolfinx/dolfinx:v0.10.0@sha256:f7cce2a2271bf838c080751348c471064acb41fef0330e2c08178a688f71890d +ARG SCATTER3D_GIT_COMMIT="" +ARG SCATTER3D_GIT_DIRTY="" + LABEL org.opencontainers.image.title="Scatter3D-Codex" \ org.opencontainers.image.description="Complex DOLFINx/PETSc verification runtime" \ org.opencontainers.image.source="https://github.com/HunterSpence/Scatter3D-Codex" \ - org.opencontainers.image.licenses="Apache-2.0" + org.opencontainers.image.licenses="Apache-2.0" \ + org.opencontainers.image.revision="${SCATTER3D_GIT_COMMIT}" \ + org.opencontainers.image.base.name="dolfinx/dolfinx:v0.10.0" \ + org.opencontainers.image.base.digest="sha256:f7cce2a2271bf838c080751348c471064acb41fef0330e2c08178a688f71890d" # Select the complex-scalar installation for non-interactive commands and cap # threaded BLAS so MPI ranks do not oversubscribe a workstation or runner. @@ -18,25 +24,69 @@ ENV PETSC_ARCH=linux-gnu-complex128-32 \ OPENBLAS_NUM_THREADS=1 \ NUMEXPR_NUM_THREADS=1 \ PYTHONUNBUFFERED=1 \ - PYTHONDONTWRITEBYTECODE=1 + PYTHONDONTWRITEBYTECODE=1 \ + SCATTER3D_GIT_COMMIT="${SCATTER3D_GIT_COMMIT}" \ + SCATTER3D_GIT_DIRTY="${SCATTER3D_GIT_DIRTY}" WORKDIR /opt/scatter3d COPY docker/requirements-test.txt /tmp/requirements-test.txt -RUN python3 -m pip install --no-cache-dir -r /tmp/requirements-test.txt \ - && python3 -c "import numpy as np; from petsc4py import PETSc; assert np.issubdtype(PETSc.ScalarType, np.complexfloating)" +RUN python3 -m pip install --no-cache-dir -r /tmp/requirements-test.txt COPY pyproject.toml README.md LICENSE ./ COPY src ./src RUN python3 -m pip install --no-cache-dir --no-deps . \ - && python3 -c "import scatter3d" + && python3 -m pip check + +# Fail during the image build if the digest no longer exposes the expected +# DOLFINx release or a complex PETSc scalar. Emit exact PETSc/MPI build metadata +# into the build log so every verification run can identify its numerical ABI. +RUN python3 - <<'PY' +import json +import os +import re +from pathlib import Path + +import dolfinx +import mpi4py +import numpy as np +import petsc4py +from mpi4py import MPI +from petsc4py import PETSc + +if re.match(r"^0\.10(?:\.|$)", dolfinx.__version__) is None: + raise SystemExit(f"expected DOLFINx 0.10.x, found {dolfinx.__version__}") +if not np.issubdtype(PETSc.ScalarType, np.complexfloating): + raise SystemExit(f"complex PETSc required, found {np.dtype(PETSc.ScalarType)}") + +metadata = { + "dolfinx": dolfinx.__version__, + "mpi_library": " ".join(MPI.Get_library_version().split()), + "mpi_standard": ".".join(str(part) for part in MPI.Get_version()), + "mpi4py": mpi4py.__version__, + "petsc": ".".join(str(part) for part in PETSc.Sys.getVersion()), + "petsc_arch": os.environ.get("PETSC_ARCH", ""), + "petsc_scalar_type": str(np.dtype(PETSc.ScalarType)), + "petsc_version_info": PETSc.Sys.getVersionInfo(), + "petsc4py": petsc4py.__version__, + "petsc_has_hpddm": bool(PETSc.Sys.hasExternalPackage("hpddm")), + "petsc_has_slepc": bool(PETSc.Sys.hasExternalPackage("slepc")), +} +payload = json.dumps(metadata, indent=2, sort_keys=True) +Path("/opt/scatter3d/runtime-metadata.json").write_text(payload + "\n", encoding="utf-8") +print("scatter3d-runtime-metadata=" + json.dumps(metadata, sort_keys=True)) +PY + +RUN python3 -c "import scatter3d" COPY tests ./tests COPY examples ./examples COPY docs ./docs +COPY validation ./validation -RUN useradd --create-home --uid 1000 scatter3d \ - && chown -R scatter3d:scatter3d /opt/scatter3d -USER scatter3d +# The pinned DOLFINx base already provides an unprivileged UID/GID 1000. +# Reuse it instead of assuming the corresponding account name is absent. +RUN chown -R 1000:1000 /opt/scatter3d +USER 1000:1000 CMD ["python3", "-m", "scatter3d.cli", "--help"] diff --git a/docker/compose.yaml b/docker/compose.yaml index 284e369..6e25ce4 100644 --- a/docker/compose.yaml +++ b/docker/compose.yaml @@ -6,9 +6,22 @@ services: image: scatter3d-codex:local working_dir: /work volumes: - - ..:/work + - ..:/work:ro environment: OMP_NUM_THREADS: "1" MKL_NUM_THREADS: "1" OPENBLAS_NUM_THREADS: "1" - command: ["python3", "-m", "pytest", "-m", "heavy", "-ra"] + PYTHONPATH: /work/src:/usr/local/dolfinx-complex/lib/python3.12/dist-packages:/dolfinx-env/lib/python3.12/site-packages:/usr/local/lib + command: + [ + "python3", + "-m", + "pytest", + "-p", + "no:cacheprovider", + "-W", + "error", + "-m", + "heavy and not mpi", + "-ra", + ] diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d102f9a..02cd4fb 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -47,13 +47,18 @@ prevents the calibration step from fitting away the defect signal. ### Inverse `scatter3d.inverse` keeps complex arithmetic native. It estimates noise from -repeat differentials, whitens `A` and `b` together, and returns TSVD diagnostics -including available and selected rank, singular values, residuals, solution -norm, condition estimate, and the rank-selection criterion. +paired same-index repeat differentials, divides sample variance by the paired +repeat count for the variance of the mean, whitens `A` and `b` together, and +returns TSVD diagnostics including available and selected rank, the full compact +singular spectrum, residuals, solution norm, condition estimate, numerical-rank +threshold, and the complete rank-selection criterion curve. The default GCV choice is useful when a noise norm is unavailable; discrepancy selection is preferable when repeat measurements supply a defensible noise -norm. A fixed rank is intended for reproducibility tests, not visual tuning. +norm. The automatic whitened `sqrt(rows)` target is only an expected RMS +heuristic. An unmet discrepancy target produces a `FAILED` artifact and nonzero +exit unless explicitly allowed; the override never converts the result to +`PASSED`. A fixed rank is intended for reproducibility tests, not visual tuning. ### FEM @@ -62,16 +67,22 @@ norm. A fixed rank is intended for reproducibility tests, not visual tuning. - `config`: immutable materials, PML, Maxwell, and linear-solver settings; - `tags` and `gmsh_io`: validate mesh/physical-tag contracts; - `pml`: Cartesian complex-stretch tensors; -- `ports`: discrete per-port mode normalization and excitations; +- `ports`: explicit separation between uncalibrated surface loads and the + in-progress matched single-mode TEM boundary/mode-normalization path; - `forms`: frequency-dependent Maxwell forms; - `solver`: one matrix/preconditioner setup per frequency with successive port - right-hand sides, plus convergence diagnostics; + right-hand sides, true-residual checks, and requested/setup-observed solver + diagnostics; - `checkpoints`: identity hashes that reject stale mesh/config/frequency reuse; - `diagnostics`: true residual and material-model comparisons. -The public high-level entry is `MaxwellSweepSolver.solve(frequencies_hz, -ports)`. DOLFINx imports are kept inside the FEM layer so measurement and inverse -tests remain usable on ordinary Python installations. +The historical surface-current RHS is a generic weak load only. It does not +define a matched termination, power wave, circuit reference, or calibrated +S-parameter. The newer matched TEM forms and electric-mode normalization are +still integration work: heavy DOLFINx tests and incident/outgoing modal +extraction must land and pass before `MaxwellSweepSolver` output can be described +as a physical port result. DOLFINx imports remain inside the FEM layer so +measurement and inverse tests are usable on ordinary Python installations. Geometry order is intentionally limited to one in the initial release. Curved geometry support must not be enabled until a curved-boundary convergence test is @@ -82,8 +93,11 @@ was validated. `scatter3d.pipeline` defines a compact NPZ interchange format. It rejects unknown schema versions, pickle-backed arrays, non-finite values, coordinate -mismatches, duplicate sensitivity rows, and missing row maps. It writes the -reconstruction atomically and records input SHA-256 hashes. +mismatches, noncanonical dtypes, duplicate sensitivity rows, and missing row +maps. Measurement and sensitivity arrays use exact complex128/float64/Unicode/ +int64 contracts. It writes the reconstruction atomically, refuses to clobber an +existing artifact unless explicitly forced, and records input SHA-256 hashes, +overwrite intent, selector status, spectrum hashes, and noise-model provenance. `scatter3d.cli` deliberately exposes only offline validation, diagnosis, and inversion. Instrument control and billable or safety-relevant RF actions are not @@ -105,6 +119,39 @@ PETSc explicitly supports repeated `KSPSolve` calls for the same operator and different right-hand sides. Reusing a factorization across a changed frequency or material model is forbidden. +PETSc options are installed under a unique prefix for each KSP. Some +preconditioners, including ASM, do not create and configure nested subdomain KSP +and PC objects until `KSPSetUp`. The options must therefore remain installed +through setup and may be removed from PETSc's global options database only +afterward. Revision `c3c1ded` corrected an earlier lifecycle error that removed +them after `KSPSetFromOptions` but before setup, causing a requested subdomain LU +to remain PETSc's default ILU. The regression test proves nested options are +consumed during setup, and the corrected remote run was independently inspected +with `-ksp_view`. + +The current development solver retains both requested and setup-observed PETSc +configuration. After `KSPSetUp`, it checks the effective top-level KSP/PC, +factor backend, preconditioning side, tolerances, and iteration cap against the +typed configuration. For ASM it aggregates the live subdomain KSP/PC hierarchy +across MPI ranks, parses restriction/interpolation type and overlap from PETSc's +official ASCII view, and retains the raw view in diagnostics. A mismatch fails +closed; requested options alone are never treated as effective-runtime evidence. + +The current development forms distinguish the unchanged physical Maxwell +operator `A` from a separately assembled absorption-shifted preconditioning +operator `P`. A positive dimensionless shift adds artificial loss only to the +mass term in `P`; the physical form and all right-hand sides remain unchanged. +The solver calls `KSPSetOperators(A, P)` and always recomputes the acceptance +residual with `A`. Zero shift has explicit identity semantics and reuses `A` as +`P` without a duplicate matrix assembly. + +The solver also provides a genuine assembled p=3-to-p=1 two-level correction. +Small serial and two-rank 1,158-fine/98-coarse-DoF correctness artifacts +**PASSED** at `bee9e9d`, including shifted fine/coarse operators and live +hierarchy checks. The registered 86,103- and 470,928-DoF p-multigrid sweep is +**NOT RUN**; the small canaries are not convergence-at-scale evidence and do not +alter the historical `c3c1ded` scaling results. + ## Provenance boundary A defensible result should identify at least: @@ -120,6 +167,18 @@ A defensible result should identify at least: - solver options, convergence reasons, and true residuals; - regularization method, rank, singular values, and whitening model. +The FEM validation artifact schema +`scatter3d.validation.fem_smoke/v2` records source identity, process command, +container/base-image identities, runtime versions, cgroup memory metadata, a +canonical physical-problem identity, requested/effective solver configuration, +and per-frequency preconditioner metrics. Writes are atomic and no-clobber by +default; replacing an artifact requires explicit `--overwrite`. + +The reconstruction NPZ is itself the selector evidence artifact: it retains the +full compact singular spectrum, criterion ranks and values, status, numerical +threshold, selected rows, floors, and hashes. A JSON summary is useful for +inspection but does not replace that NPZ. + Descriptive metadata is not allowed to change a numerical fingerprint. Secrets and private dataset contents never belong in a manifest. @@ -130,4 +189,5 @@ and private dataset contents never belong in a manifest. - independently fitting reference and DUT calibration; - silently symmetrizing reciprocal channels; - a claimed nonlinear DBIM implementation; +- describing an uncalibrated surface-current load as a VNA/S-parameter port; - production-scale or real-object validation without published evidence. diff --git a/docs/CLI.md b/docs/CLI.md index 1f71a99..047e1ab 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -22,9 +22,15 @@ Validation checks schema version, complex dtype, finite values, array shapes, frequency and angle coordinates, port labels, and reference/DUT compatibility. The report contains the exact file SHA-256 and row count. +The NPZ boundary is intentionally strict: measurement tensors must be +`complex128`, coordinate arrays must be `float64`, port labels must be a NumPy +Unicode vector, sensitivity rows must be `complex128`, and optional row indices +must be `int64`. Inputs are rejected rather than silently cast. + Exit code `0` means this contract passed. Exit code `2` means the input or requested workflow was invalid. It does not mean the measurement is physically -adequate. +adequate. Existing JSON reports are protected by default; pass `--force` only +when replacing a registered artifact is intentional. ## Diagnose repeatability before imaging @@ -40,7 +46,10 @@ The report includes: - combined repeat-noise RMS when both sides have at least two repeats; - signal-to-repeat-noise ratio and dB value; - reciprocity residuals as diagnostics, not automatic corrections; -- number of exact unique angle traces, which catches array-alias failures. +- number of exact unique angle traces, which catches array-alias failures; and +- one diagnostic record for every frequency/receiver/source channel, including + indices, labels, differential RMS, repeat-noise RMS, and signal-to-noise + ratio when repeat evidence exists. A bundle with one repeat validates structurally but cannot estimate a repeat floor. Do not promote that file to a real-image result. @@ -67,6 +76,11 @@ Rank-selection modes: - `energy --energy-fraction Q`: retains a registered singular-value energy fraction; this is a matrix heuristic, not a noise model. +`--rank` is accepted only with `--method fixed`; `--noise-norm` only with +`--method discrepancy`; and `--energy-fraction` only with `--method energy`. +Energy selection defaults to `0.999` only when energy mode is selected and no +fraction is supplied. + The pipeline always forms the same-index differential and checks frequency, angle, port, and sensitivity-row identity before solving. `--channel-mode` selects both `A` and `b`; it cannot reorder either. @@ -77,7 +91,28 @@ is divided by the repeat count to obtain variance of the mean, and `A` and `b` are scaled together. Use `--whitening required` to fail if that evidence is unavailable, or `--whitening off` for a registered unweighted control. After whitening, discrepancy mode defaults to `sqrt(rows)` unless `--noise-norm` -explicitly supplies a solve-space target. +explicitly supplies a solve-space target. This default is only the expected RMS +scale when each whitened complex row obeys `E|z_i|^2 = 1`; it is not a confidence +bound or proof that the noise model is correct. + +`--noise-relative-floor` and `--noise-absolute-floor` control the variance floor +before diagonal whitening. Both must be finite and non-negative. The relative +floor is multiplied by the median positive variance; if all estimated variances +are zero, a positive absolute floor is required for whitening instead of +inventing a unit-scale floor. + +Every reconstruction NPZ contains the full compact singular-value spectrum, +the selector's candidate ranks and criterion curve, numerical-rank threshold, +selected and available ranks, residuals in raw and solve space, input hashes, +and `PASSED`/`FAILED` status. For discrepancy selection, failure to meet the +target writes a `FAILED` artifact and returns exit code `1`. The explicit +`--allow-unmet-discrepancy` option changes that exit code to `0`; it does not +change the artifact status or make the target met. + +The reconstruction NPZ and optional JSON report are no-clobber by default. +`--force` authorizes replacement of both and is recorded as +`overwrite_requested=true` in the reconstruction artifact. Validation and +diagnosis JSON reports follow the same no-clobber default. ## End-to-end software canary @@ -108,3 +143,11 @@ sha256sum "$BUNDLE" "$SENSITIVITY" "$RUN/reconstruction.npz" > "$RUN/SHA256SUMS" Archive the Git revision, container digest, FEM manifest, raw-export hashes, and experiment log with this directory. Never overwrite an earlier run in place. + +## Exit codes + +- `0`: command completed, and any discrepancy target was met or an explicit + `--allow-unmet-discrepancy` override was supplied; +- `1`: discrepancy reconstruction completed but no available TSVD rank met the + target; +- `2`: invalid arguments, schema/input error, missing file, or refused overwrite. diff --git a/docs/CONTINUATION_HANDOFF_2026-07-13.md b/docs/CONTINUATION_HANDOFF_2026-07-13.md new file mode 100644 index 0000000..f09f096 --- /dev/null +++ b/docs/CONTINUATION_HANDOFF_2026-07-13.md @@ -0,0 +1,688 @@ +# Continuation handoff — 2026-07-13 + +> **Post-checkpoint update:** campaign-03 reached an attested, bootstrapped +> disposable runner, then remote preparation **FAILED** before source checkout +> because GNU `df` rejects combining `-P` with `--output=avail`. Both canaries, +> registration, and all eight solver runs are **NOT RUN**. All attempt-owned +> provider resources and the ephemeral key were deleted and independently +> verified absent. See the [sanitized failure record](evidence/campaign-03-preparation-failure.json). + +## Purpose + +This document is the restart point for completing the strongest honest public +release supported by evidence. It is intentionally public-safe: it contains no +credentials, control-host details, SSH identities, server identifiers, or private +measurement/source material. + +The project is an independently authored Apache-2.0 clean-room implementation. +It is not a fork or relicensing of third-party/private Scatt3D source for which +no compatible public license was verified. No code or private data from that +source may be copied, merged, cherry-picked, or published here. + +## Exact checkpoint before this handoff + +| Item | Exact value | +|---|---| +| Code checkpoint | `d5fe81486d0d8bf2611d4b4f8df8fa4b9aaf6ad4` | +| Branch | `codex/verification-release-20260712` | +| Preserved original checkpoint | `codex/checkpoint-20260712` at `85c40392e711cc2749f85330409951c4bf7f4824` | +| Pull request | [PR #1](https://github.com/HunterSpence/Scatter3D-Codex/pull/1), OPEN and MERGEABLE at the checkpoint | +| Exact CI | [GitHub Actions run 29214242189](https://github.com/HunterSpence/Scatter3D-Codex/actions/runs/29214242189), **PASSED** | +| Dedicated benchmark runner | None active; no Scatter3D benchmark runner was billing at handoff | + +The commit containing this document is documentation-only relative to the code +checkpoint above. At restart, treat the current pushed PR head and its own exact +CI result as authoritative; do not assume a stale local `HEAD` is current. + +## First commands at restart + +Run these read-only checks before editing or provisioning compute: + +```powershell +$repo = (Get-Location).Path # run from the Scatter3D-Codex checkout +git -C $repo fetch origin +git -C $repo status --short --branch +git -C $repo log -5 --oneline --decorate +git -C $repo rev-parse HEAD +git -C $repo rev-parse origin/codex/verification-release-20260712 +gh pr view 1 --repo HunterSpence/Scatter3D-Codex ` + --json url,state,mergeable,headRefOid,statusCheckRollup +gh run view 29214242189 --repo HunterSpence/Scatter3D-Codex --exit-status +``` + +Required result before continuing: + +- local worktree clean; +- local and remote branch synchronized; +- PR head exact and mergeable; +- all checks on the current PR head **PASSED**; +- no pre-existing dedicated benchmark runner that could be double-billed. + +If the PR head moved because this handoff was committed, use the newer exact-head +CI run. Run 29214242189 remains the immutable evidence for `d5fe814`. + +## What is already complete — do not redo it + +### Measurement and inverse contracts + +- Canonical `S[angle, frequency, receiver, source]` ordering and explicit + receiver/source meaning. +- Strict indexed long-form CSV import with coordinate, order, dtype, uniqueness, + and hash validation. +- Same-index DUT-reference subtraction and reference-only optional alignment, + disabled by default. +- Paired repeat-differential covariance, variance-of-the-mean handling, + diagonal O(N) whitening, a guarded dense-covariance path, and explicit noise + floors. +- Native-complex TSVD with fixed, GCV, discrepancy, and singular-energy + selection plus complete diagnostics and no-clobber artifacts. +- Volume-weighted reconstruction metrics, deterministic provenance, CLI, + examples, schemas, packaging, Docker, and CI. + +### Maxwell solver + +- DOLFINx 0.10/PETSc 3.24 complex128 3-D Maxwell forms for p=1/2/3 Nedelec + fields with linear geometry. +- Independent REF/DUT material maps, Cartesian PML support, validated mesh/tag + contracts, matched single-mode TEM software forms, repeated-RHS reuse, direct + MUMPS reference, and iterative FGMRES paths. +- Physical Maxwell matrix `A` remains unshifted. A nonzero absorption shift is + applied only to a separate preconditioning matrix `P` through + `KSPSetOperators(A, P)`. +- Genuine two-level p=3-to-p=1 p-multigrid: PEC-masked interpolation, + one-step Richardson/ASM fine smoothing with local MUMPS LU, and a global p=1 + MUMPS coarse correction. +- Requested and setup-observed PETSc hierarchy, raw KSP view, operator identity, + true residual, DoF, nonzero, timing, RSS, factorization, and repeated-RHS + counters. + +### Fail-closed scaling tooling + +`validation/scaling_sweep_v1.json` and the registration/executor added at +`d5fe814` establish: + +- eight immutable runs: n=9/MPI4 and n=16/MPI8, each at absorption shifts + `0.0`, `0.25`, `0.5`, and `1.0`; +- p=3 fine and p=1 coarse fields, 100 MHz, and two independent port RHS solves; +- exact DoF gates of 86,103 and 470,928; +- recomputed true relative residual at most `1e-7` and 1,000-iteration cap; +- 28 GiB cgroup memory limit, no swap, and 10,800-second wall-time cap per run; +- clean source, immutable image/base/runtime, complete physical-problem identity, + and per-run output binding; +- write-once registration and run directories, separate stdout/stderr, + `fem-smoke.json`, `exit-code.json`, and `SHA256SUMS`; +- exact-DoF mismatch preflight before any RHS solve; +- a Docker supervisor handshake that keeps PID 1 and the cgroup alive until the + host captures positive `memory.peak`, `memory.max`, `memory.swap.max`, and + `memory.events` before container cleanup; +- attempt-specific Docker ownership labels plus a stable absence window after + ambiguous create timeouts, without deleting a pre-existing name conflict; +- honest preservation of `FAILED` solver results instead of converting them to + skips. + +Do not replace the supervisor with `docker start --attach` or read cgroup metrics +only after `docker wait`; the kernel cgroup may already have disappeared. + +## Exact automated evidence at `d5fe814` + +Run 29214242189 recorded all eight jobs **PASSED**: + +| Gate | Result | +|---|---| +| Repository/static/security/actionlint/Compose/Bash/CFF/links | **PASSED** | +| Wheel and sdist build plus clean installs | **PASSED** | +| CPython 3.11 | **PASSED**: 182 passed, 23 deselected | +| CPython 3.12 | **PASSED**: 182 passed, 23 deselected | +| CPython 3.13 | **PASSED**: 182 passed, 23 deselected | +| CPython 3.14 | **PASSED**: 182 passed, 23 deselected | +| Complex DOLFINx heavy | **PASSED**: 21 passed, 184 deselected, zero skips | +| MPI selection on two ranks | **PASSED**: 2 logical tests passed, 203 deselected, zero skips | + +Each MPI rank's JUnit file records the same two selected tests; do not describe +that as four independent tests. + +### Runtime identity + +| Item | Value | +|---|---| +| Base digest | `sha256:f7cce2a2271bf838c080751348c471064acb41fef0330e2c08178a688f71890d` | +| Heavy project image ID | `sha256:516786a15a4f17e5f31641d6ae78a25849c12f3d2b6a4012e72ad349bc6c12c8` | +| MPI project image ID | `sha256:1b67bc8ae057c62c242619cb133ae9ae02782e7a13b0521226718ea8f7471539` | +| DOLFINx | 0.10.0 | +| PETSc / petsc4py | 3.24.0 / 3.24.0, complex128 | +| PETSc arch | `linux-gnu-complex128-32` | +| mpi4py / MPI | 4.1.1 / MPICH 4.3.1 | +| HPDDM external package | false | +| SLEPc external package | false | + +The two project image IDs differ because the heavy and MPI jobs built separate +local images. Both bind the same source revision and base digest. + +### Current CI artifact hashes + +Both downloaded `SHA256SUMS` files were independently rechecked: 8/8 targets +**PASSED** in each artifact set. + +| Artifact | SHA-256 | +|---|---| +| Heavy `SHA256SUMS` | `e54040571663e7f1f5d90eb47e6eef419df4f309de274300255175c75eaba6d1` | +| MPI `SHA256SUMS` | `bb82fca8be7021d65fefbdfe1990714b55df2cbb59dcfdca100b06563075594c` | +| `fem-smoke-direct-p3.json` | `0a672f8a39a626144f7763bb5e7cbd81d0cfd604abb299fe23be61a9576e602c` | +| `fem-smoke-pmg-serial-p3-p1.json` | `007482af744c96c92beda86b107a2bc5204d84335270093e14d1e251d91d7253` | +| `fem-smoke-iterative-mpi2.json` | `7f40ae679b50c17d205866d56dc4069438c184db4a7a195751c731bbc88fea6a` | +| `fem-smoke-pmg-mpi2-p3-p1.json` | `98515035ccf623a1ec111889b5c4cc2da4cdc5ccd413e76860bd0ab623df2c15` | +| `manufactured-hcurl.json` | `70197800b299b65ba1f05a55f376f9fd3187611ab655788916e81ab62d72081c` | +| Wheel | `bf61f83f357eed5cb3847c598604e20c7b1dc29a1e71645a7efa438ba45aa7c9` | +| Sdist | `9f84a0edbb6355062fc1289a7b0203d113b27513491d07653079a953cc8b82cc` | + +GitHub Actions artifacts expire. Attach the needed JSON/manifests to any release +that cites them. + +### Current small solver results + +| Case | Result | +|---|---| +| Direct p=3 serial | **PASSED**: 1,158 DoFs, two RHS, residuals `9.35e-15` and `7.48e-15` | +| p=3-to-p=1 pMG serial | **PASSED**: 1,158 fine / 98 coarse DoFs, 6/6 iterations, residuals `1.615e-10` and `1.417e-10` | +| One-level MPI2 correctness | **PASSED**: 98 DoFs, 13/14 iterations, residuals `6.75e-9` and `1.97e-9` | +| p=3-to-p=1 pMG MPI2 | **PASSED**: 1,158 fine / 98 coarse DoFs, 10/10 iterations, residuals `1.246e-9` and `1.298e-9` | + +These are correctness cases, not mesh-scaling or memory evidence. + +## Historical large-run evidence — unchanged + +All historical results below use the one-level path at `c3c1ded`, not the new +two-level pMG sweep. + +| Experiment | Status | Evidence | +|---|---|---| +| p=3, n=9, MPI4, 86,103 DoFs, ASM/ILU(0) | **FAILED** | Both RHS hit 1,000 iterations; best true residual about `4.93e-3` | +| p=3, n=9, MPI4, ASM/local MUMPS LU | **PASSED** | 233/230 iterations; residuals `9.3958e-9`, `9.9821e-9` | +| Identical direct MUMPS problem | **PASSED** | Residuals `9.59e-14`, `9.83e-14` | +| Same-problem peak-memory ratio at 86,103 DoFs | **FAILED** | Iterative `2,544,521,216`; direct `3,079,335,936`; ratio `0.8263214111` > `0.5` | +| p=3, n=16, MPI8, 470,928 DoFs, one-level local MUMPS LU | **FAILED** | Both RHS hit 1,000 iterations; residuals `2.4996e-7`, `5.5071e-6` | + +Historical hashes: + +- corrected local-LU n=9: `f7e7b1ffbb2830a0b80089ad58699b93db101e24cbbfb445a615c7e6ca2b0a89`; +- n=9 direct: `00be6c3560c39bb4f23b32409c5ff9a7c94298fa7b73c5e9b3fe9f5072fe4196`; +- n=9 memory gate: `5caf2f39e05106307d1c11aab4ac55eb80fb347d6b9c97f25cf8e72010d8f3ab`; +- n=9 ILU: `da385b3cc910970f97b1f81dd7cb9d4f1fb1cb3f38a256f5305bccda319ae2a2`; +- n=16 one-level LU: `e0f0e40bd1d1b100645087da3bbe22821682afc939455d0e16fa51957bdbddb7`. + +Important: `p3-n9-mpi4-right-asm1-lu.json` with SHA-256 +`bf437d5ac34ce1418a654c454628b5657b3866f0282b15882f1f988f74ceb362` +was generated before the PETSc option-lifecycle fix. It actually used ILU and +must never be cited as local-LU evidence. + +These hashes were recomputed from the retained local evidence mirror. Reverify +the canonical files again before attaching them to a release. + +## PETSc root cause already fixed + +Prefixed options were once removed after `KSPSetFromOptions` but before +`KSPSetUp`. ASM creates/configures nested subdomain solvers during setup, so a +requested subdomain LU silently remained default ILU. `c3c1ded` retains the +options through setup and removes them in a `finally` block afterward. Heavy +regressions deliberately request invalid nested configuration and prove setup +consumes it. Effective hierarchy is now recorded, not inferred from requested +options. + +Do not undo this lifecycle or trust requested PETSc options without setup-observed +evidence. + +## Required next execution — in order + +### 1. Select ephemeral Linux capacity + +Use a disposable Linux Docker runner. Keep credential lookup, account details, +server IDs, IPs, SSH identities, and deletion API output outside this public +repository. Before provisioning, privately verify that no prior Scatter3D +benchmark runner remains active. + +Preferred minimum for the registered sweep: + +- 16 CPU threads; +- approximately 32 GiB RAM; +- at least 100 GiB free disk; +- Ubuntu 24.04 or equivalent; +- Docker with cgroup v2; +- no host swap; +- inbound firewall allowing SSH only. + +Recheck larger-memory capacity before later 1M/3M work. Do not begin a charge +for an unavailable dedicated type, and do not leave a paid runner unattended. + +### 2. Clone the exact pushed source and build an identity-bound image + +```bash +set -euo pipefail +: "${VERIFIED_COMMIT:?set VERIFIED_COMMIT to the exact green PR headRefOid}" +git clone https://github.com/HunterSpence/Scatter3D-Codex.git repo +cd repo +git fetch origin "$VERIFIED_COMMIT" +git checkout --detach "$VERIFIED_COMMIT" +test -z "$(git status --porcelain --untracked-files=normal)" +SOURCE_COMMIT=$(git rev-parse HEAD) +test "$SOURCE_COMMIT" = "$VERIFIED_COMMIT" + +docker build --file docker/Dockerfile \ + --build-arg SCATTER3D_GIT_COMMIT="$SOURCE_COMMIT" \ + --build-arg SCATTER3D_GIT_DIRTY=false \ + --tag scatter3d-codex:bench . + +IMAGE_ID=$(docker image inspect --format '{{.Id}}' scatter3d-codex:bench) +mkdir -p ../evidence +docker image inspect "$IMAGE_ID" > ../evidence/image-inspect.json +docker run --rm --entrypoint cat "$IMAGE_ID" \ + /opt/scatter3d/runtime-metadata.json > ../evidence/runtime-metadata.json +docker info > ../evidence/docker-info.txt +uname -a > ../evidence/uname.txt +test "$(stat -fc %T /sys/fs/cgroup)" = cgroup2fs +``` + +Require the image labels and environment to contain the exact commit and +`dirty=false`. Require the base digest and complex runtime to match the pinned +contract. + +Keep `image-inspect.json`, `docker-info.txt`, and `uname.txt` private until they +are sanitized. Remove hostnames, proxy/registry configuration, host labels, and +other account/infrastructure identifiers before attaching any excerpt publicly. + +### 3. Linux lifecycle canary before the first paid solve + +Exercise the real supervisor sequence on Linux before releasing an expensive +registered solve: + +```text +container .executor-ready +host .executor-go +child exits and writes .solver-exit-code + .solver-done +PID 1 remains alive +host resolves /proc//cgroup and reads cgroup-v2 memory files +host writes .executor-collected +container exits with the child code +host waits, inspects, logs, removes, and verifies cleanup +``` + +Acceptance: + +- positive host `memory.peak`; +- `memory.max` equals the requested limit; +- `memory.swap.max` equals zero; +- `memory.events` contains required counters; +- child and Docker exit codes agree; +- supervisor control files are removed; +- cleanup attempted and succeeded; +- no leftover container; +- output files mode 0644 and manifests verify. + +Run this cheap live-Docker canary. Its overall status is deliberately **FAILED** +because the tiny child does not publish a FEM artifact; all lifecycle/resource +assertions must nevertheless pass: + +```bash +CANARY_PARENT=../evidence/lifecycle-canary +mkdir -p "$CANARY_PARENT" +python3 - "$IMAGE_ID" "$CANARY_PARENT" <<'PY' +import json +import sys +from pathlib import Path + +from validation.run_registered_scaling_sweep import execute_registered_entries + +image_id = sys.argv[1] +parent = Path(sys.argv[2]).resolve() +memory = 256 * 1024**2 +output_root = "lifecycle-smoke-output" +run_id = "lifecycle-smoke" +run_root = f"{output_root}/{run_id}" +entry = { + "run_id": run_id, + "resource_contract": { + "mpi_ranks": 1, + "cgroup_memory_limit_bytes": memory, + "wall_time_limit_seconds": 30, + }, + "command": [ + "python3", + "-c", + "import time; x=bytearray(8*1024*1024); time.sleep(0.5); print(len(x))", + ], + "outputs": { + "directory": run_root, + "container_directory": "/artifacts/lifecycle-smoke", + "fem_smoke_json": f"{run_root}/fem-smoke.json", + "container_fem_smoke_json": "/artifacts/lifecycle-smoke/fem-smoke.json", + "stdout_log": f"{run_root}/stdout.log", + "stderr_log": f"{run_root}/stderr.log", + "exit_code_json": f"{run_root}/exit-code.json", + "sha256_manifest": f"{run_root}/SHA256SUMS", + }, +} +registration = { + "registration_id": "lifecycle-smoke", + "output_root": output_root, + "images": { + "project_image": {"kind": "local_image_id", "identity": image_id}, + "base_image": { + "kind": "oci_digest", + "identity": "sha256:f7cce2a2271bf838c080751348c471064acb41fef0330e2c08178a688f71890d", + }, + }, + "entries": [entry], +} +result = execute_registered_entries( + registration, + image_reference=image_id, + output_parent=parent, +)[0] +assert result["status"] == "FAILED" +assert result["reason"] == "registered solve produced no fem_smoke artifact" +assert result["docker_return_code"] == 0 +assert result["solver_barrier_exit_code"] == 0 +assert result["launch_prevented"] is False +assert result["timed_out"] is False +assert result["container_resource_contract_passed"] is True +assert result["container_cleanup_attempted"] is True +assert result["container_cleanup_succeeded"] is True +assert result["container_absence_verified"] is True +assert result["host_cgroup"]["peak_bytes"] > 0 +assert result["host_cgroup"]["limit_bytes"] == memory +assert result["host_cgroup"]["swap_limit_bytes"] == 0 +assert {"oom", "oom_kill", "max"} <= result["host_cgroup"]["events"].keys() +print(json.dumps(result, indent=2, sort_keys=True)) +PY +CANARY_RUN_DIR="$CANARY_PARENT/lifecycle-smoke-output/lifecycle-smoke" +test -z "$(docker ps -aq --filter label=scatter3d.registration_id=lifecycle-smoke)" +test -z "$(find "$CANARY_RUN_DIR" -maxdepth 1 \ + \( -name '.executor-*' -o -name '.solver-*' \) -print -quit)" +for file in stdout.log stderr.log exit-code.json SHA256SUMS; do + test "$(stat -c %a "$CANARY_RUN_DIR/$file")" = 644 +done +(cd "$CANARY_RUN_DIR" && sha256sum -c SHA256SUMS) +``` + +Then run the small wrong-DoF heavy canary already covered in CI: + +```bash +WRONG_DOF_DIR=../evidence/wrong-dof-canary +mkdir -p "$WRONG_DOF_DIR" +chmod 0777 "$WRONG_DOF_DIR" +set +e +docker run --rm --memory=536870912 --memory-swap=536870912 \ + --env SCATTER3D_PROJECT_IMAGE_ID="$IMAGE_ID" \ + --env SCATTER3D_BASE_IMAGE_DIGEST=sha256:f7cce2a2271bf838c080751348c471064acb41fef0330e2c08178a688f71890d \ + --volume "$(realpath "$WRONG_DOF_DIR"):/artifacts" \ + "$IMAGE_ID" python3 validation/fem_smoke.py \ + --solver iterative \ + --iterative-hierarchy p-multigrid \ + --p-multigrid-coarse-degree 1 \ + --iterative-local-pc lu \ + --degree 3 \ + --subdivisions 2 \ + --frequencies-hz 1.0e8 \ + --expected-global-dofs 1 \ + --output /artifacts/wrong-dof.json \ + > "$WRONG_DOF_DIR/stdout.log" 2> "$WRONG_DOF_DIR/stderr.log" +wrong_dof_rc=$? +set -e +test "$wrong_dof_rc" -eq 1 +test "$(stat -c %a "$WRONG_DOF_DIR/wrong-dof.json")" = 644 +python3 - "$WRONG_DOF_DIR/wrong-dof.json" "$VERIFIED_COMMIT" <<'PY' +import json +import sys +from pathlib import Path + +payload = json.loads(Path(sys.argv[1]).read_text()) +assert payload["status"] == "FAILED" +assert payload["passed"] is False +assert payload["execution_phase"] == "preflight" +assert payload["source"]["commit"] == sys.argv[2] +assert payload["gates"]["expected_global_dofs"]["status"] == "FAILED" +for name in ( + "convergence_and_true_residual", + "assembly_setup_rhs_counts", + "p_multigrid_structure", +): + assert payload["gates"][name]["status"] == "NOT RUN" +assert "rhs_solves" not in payload +PY +``` + +Any lifecycle/cgroup failure is **FAILED**. Fix it in a normal commit and obtain +new exact-head CI before registering or running the scaling sweep. + +### 4. Create the registration once, outside the clean source tree + +```bash +python3 validation/register_scaling_sweep.py \ + --repository . \ + --project-image-kind local_image_id \ + --project-image-identity "$IMAGE_ID" \ + --base-image-digest sha256:f7cce2a2271bf838c080751348c471064acb41fef0330e2c08178a688f71890d \ + --base-runtime-metadata ../evidence/runtime-metadata.json \ + --output ../evidence/registration.json + +(cd ../evidence && sha256sum registration.json > registration.SHA256) +(cd ../evidence && sha256sum -c registration.SHA256) +test -z "$(git status --porcelain --untracked-files=normal)" +``` + +Never edit or replace this registration after the first solve begins. + +### 5. Execute all eight registered runs sequentially + +Run IDs, in order: + +1. `p3-n9-mpi4-shift-0p00` +2. `p3-n9-mpi4-shift-0p25` +3. `p3-n9-mpi4-shift-0p50` +4. `p3-n9-mpi4-shift-1p00` +5. `p3-n16-mpi8-shift-0p00` +6. `p3-n16-mpi8-shift-0p25` +7. `p3-n16-mpi8-shift-0p50` +8. `p3-n16-mpi8-shift-1p00` + +For each ID: + +```bash +RUN_ID=p3-n9-mpi4-shift-0p00 # replace with the next registered ID +set +e +python3 validation/run_registered_scaling_sweep.py \ + --registration ../evidence/registration.json \ + --repository . \ + --image "$IMAGE_ID" \ + --output-parent ../evidence \ + --run-id "$RUN_ID" +executor_rc=$? +set -e + +RESULT_JSON=$(python3 - "$RUN_ID" <<'PY' +import json +import sys +from pathlib import Path + +registration = json.loads(Path("../evidence/registration.json").read_text()) +entry = next(item for item in registration["entries"] if item["run_id"] == sys.argv[1]) +print(Path("../evidence") / entry["outputs"]["exit_code_json"]) +PY +) +RESULT_STATUS=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["status"])' "$RESULT_JSON") + +case "$RESULT_STATUS:$executor_rc" in + PASSED:0) ;; + FAILED:1) echo "$RUN_ID preserved as FAILED; inspect and archive before continuing" ;; + BLOCKED:1) echo "$RUN_ID is BLOCKED; archive and stop for the external blocker"; exit 1 ;; + *) echo "inconsistent executor result for $RUN_ID"; exit 1 ;; +esac +``` + +The executor exits nonzero for an honest `FAILED` run. Preserve the directory +and inspect it before continuing. The executor stops a multi-ID invocation after +any `FAILED` or `BLOCKED` entry; after independently confirming a numerical-only +failure and a safe runner/evidence state, launch the next unstarted registered ID +explicitly with `--run-id`. Do not rerun or overwrite the same run ID. An +interrupted entry remains preserved; only remaining unstarted IDs can proceed. + +Stop immediately for corrupted evidence, source/image mismatch, missing cgroup +instrumentation, Docker instability, disk pressure, unsafe resource state, or +inability to archive/delete the runner. Do not parameter-fish or publish only +the best shift. + +### 6. Inspect and mirror after every run + +For each completed run require: + +- `fem-smoke.json` when fem_smoke reached normal or preflight artifact + publication. Timeout, OOM, interpreter failure, or rank crash may instead + produce only `exit-code.json`, cgroup/state evidence, and logs; that absence is + preserved **FAILED** evidence, not a reason to relabel it **BLOCKED**; +- `exit-code.json` with the exact registration/run IDs; +- stdout and stderr retained separately; +- child/Docker exit parity or explicit failure reason; +- host cgroup peak/limit/swap/events; +- complete requested/effective pMG hierarchy; +- two RHS diagnostics and recomputed true residuals when a solve ran; +- successful container cleanup for any **PASSED** result; +- `SHA256SUMS` verifies. + +Mirror each completed directory to durable storage before proceeding when +practical. A numerical failure is evidence, not trash. + +### 7. Archive and deprovision on every stop path + +Before deleting the runner, archive: + +- registration plus its hash; +- all eight completed/uncompleted status directories that exist; +- every JSON, stdout/stderr log, and per-run manifest; +- image inspection and runtime metadata; +- exact Git revision and clean-status evidence; +- Docker/OS/cgroup identity; +- secret-free provisioning metadata. + +Verify all per-run manifests after the copy, then make and verify a top-level +manifest. Only after successful archival, delete the paid runner and privately +verify it no longer exists. Never leave it billing overnight unattended. + +## Acceptance and status rules + +Use only `PASSED`, `FAILED`, `NOT RUN`, and `BLOCKED` for gates. + +### PASSED + +A registered scaling run is **PASSED** only if all are true: + +- correct registration, source, image, base, runtime, command, and full physical + problem identities; +- exact global DoFs; +- both independent port RHS solves have positive PETSc convergence reason; +- both recomputed true relative residuals are at most `1e-7`; +- requested and effective pMG hierarchy/counts/operator identities agree; +- physical `A` remains unshifted and shift applies only to `P`; +- host cgroup peak is positive, 28 GiB limit is applied, swap is zero; +- no OOM, timeout, malformed state, corrupt artifact, or missing instrumentation; +- cleanup succeeds and all manifests verify. + +### FAILED + +Mark **FAILED** when a launched solve or required instrumentation misses a gate, +including iteration cap, residual, exact DoF, hierarchy, OOM, timeout, malformed +evidence, invalid cgroup data, or cleanup failure that prevents a pass claim. +Preserve it. + +### BLOCKED + +Mark **BLOCKED** only when the registered solve could not launch or an external +capacity/input prerequisite prevented execution. `BLOCKED` is not a pass and +must not replace a numerical failure. + +### NOT RUN + +Mark **NOT RUN** only when no attempt occurred for that gate. + +## Decision after the eight-run sweep + +- If all n=9 and n=16 runs finish, document every result and shift. +- If one or more n=16 runs **PASSED**, do not jump ad hoc to 1M/3M. Add the next + rung through a new reviewed registration/specification and exact-head CI. +- If all pMG runs **FAILED**, retain them, analyze iteration/residual/effective + hierarchy, and design the next coarse strategy without raising the iteration + cap cosmetically. +- The current approximately 32 GiB runner is unlikely to support an assembled + 3M-DoF problem or a same-problem direct reference. Obtain adequate high-memory + capacity before attempting that claim. +- If direct cannot run on the same 3M problem, write `3M MEMORY RATIO NOT PROVEN`. + A smaller common problem cannot establish the 3M ratio. + +## Truth boundary that must survive release work + +| Claim | Current status | +|---|---| +| Measurement ordering/schema/hash/noise/TSVD software | **PASSED** on identified automated tests | +| Manufactured H(curl) p=1/2/3 | **PASSED** on archived evidence | +| Small direct and MPI solver correctness | **PASSED** | +| Small p=3-to-p=1 pMG serial/MPI correctness | **PASSED** | +| Historical one-level 86,103-DoF local-LU solve | **PASSED** | +| Historical one-level 470,928-DoF solve | **FAILED** | +| Historical same-problem <=50% memory gate | **FAILED**, ratio `0.8263214111` | +| New pMG 86,103/470,928 sweep | **NOT RUN** until the eight artifacts exist | +| Convergence at >=3,000,000 complex DoFs | **NOT RUN** | +| 3M same-problem <=50% memory ratio | **NOT RUN** / not proven | +| Matched-TEM boundary and electric-mode normalization software checks | **PASSED** | +| Calibrated incident/outgoing E/H extraction and physical S-parameters | **NOT RUN** | +| Independent analytical scattering and transmission-line/thru benchmarks | **NOT RUN** | +| PML reflection campaign, sensitivity, and linearization range | **NOT RUN** | +| HPDDM path in the pinned image | **BLOCKED**: HPDDM and SLEPc packages unavailable | +| Real POM/PLA reconstruction, hardware ordering, and real-data diagnosis | **BLOCKED** pending an accepted raw measurement bundle | + +Never claim stable, production-ready, first, validated VNA imaging, +mesh-scalable, a memory win, million/3M capability, or validated physical +S-parameters without the corresponding evidence. + +## Required real-data bundle + +Real POM/PLA acceptance remains **BLOCKED** until an approved bundle contains: + +- indexed raw complex reference and DUT repeats; +- stationary, motion/reseat, null, and known-target controls; +- calibration plane/state and complete frequency/angle/source/receiver/port + coordinates; +- material properties with uncertainty; +- geometry/CAD/mesh identity and protocol metadata; +- acquisition settings and time/temperature/cable-motion records. + +Private or unreviewed inputs must not be published or converted into a success +claim. + +## Final merge and experimental release sequence + +After remote evidence is archived and the paid runner is deleted: + +1. Update README, verification, scaling evidence, changelog, and release notes + with every run, command, hash, identity, and honest status. +2. Run local static/pure/package checks and push normally. +3. Require all checks **PASSED** on the final PR head. +4. Download and verify final CI artifacts/manifests. +5. Merge PR #1 normally; never force-push `main`. +6. Require CI **PASSED** on the actual `main` merge commit. +7. Tag that exact green `main` commit `v0.1.0`. +8. Publish an Experimental/Pre-release, not a stable release. +9. Attach wheel, sdist, runtime metadata, public/sanitized JSON evidence, every + cited failure, and verified manifests. + +Acceptable headline: + +> Experimental v0.1.0 verification-first clean-room software infrastructure +> with tested measurement/inverse contracts and small FEM correctness evidence. + +## Public/private boundary + +The public release may contain source, synthetic fixtures, public CI artifacts, +sanitized benchmark evidence, and generic ephemeral-runner specifications. + +It must not contain credentials, vault instructions, control-host details, +account/quota data, server IDs/IPs, SSH identities, local-user paths, unsanitized +remote logs, third-party private source/data/CAD, or raw VNA measurements +without explicit publication approval and acceptance-protocol success. diff --git a/docs/CONVERGENCE_PROTOCOL.md b/docs/CONVERGENCE_PROTOCOL.md index b85b476..1fc9906 100644 --- a/docs/CONVERGENCE_PROTOCOL.md +++ b/docs/CONVERGENCE_PROTOCOL.md @@ -34,7 +34,8 @@ quadrature degree: frequency and port selection: material values, loss convention, and source: PML thickness/profile/target reflection: -KSP/PC options and tolerances: +requested and effective KSP/PC hierarchy, options prefix, and tolerances: +physical operator and preconditioning-operator identities: ``` The pinned image selects complex PETSc and caps threaded math libraries at one. @@ -236,10 +237,59 @@ global factorization. Compare both on the largest problem the direct profile can solve and require agreement of fields/S-parameters within the registered algebraic tolerance. +The one-level profile is a portability and correctness baseline, not a scalable +endpoint. Executed p=3 evidence at `c3c1ded` is mixed and must be reported as +such: + +- 86,103 global complex DoFs with ASM overlap 1 and corrected local MUMPS LU: + **PASSED** for both right-hand sides; +- peak memory at most 50% of direct on the identical 86,103-DoF problem: + **FAILED**, with summed rank peak-RSS ratio `0.8263214111`; +- 470,928 global complex DoFs with the same one-level method: **FAILED**, with + both right-hand sides reaching the 1,000-iteration cap; +- at least 3,000,000 global complex DoFs: **NOT RUN**. + +See [Distributed solver scaling evidence](SCALING_EVIDENCE.md) for the exact +revision, runtime digests, residuals, and artifact hashes. + +The current development source keeps the physical Maxwell matrix as `A`, +assembles a separate absorption-shifted/lossy Maxwell matrix as `P`, and uses +right-preconditioned FGMRES through `KSPSetOperators(A, P)`. The shift does not +change `A` or any right-hand side, and zero shift explicitly aliases `P` to `A`. +Validation schema `scatter3d.validation.fem_smoke/v2` records matrix metrics, +the shift, requested and effective PETSc hierarchy, raw ASCII KSP view, +provenance, physical-problem identity, and cgroup metadata. It is no-clobber by +default. Exact-revision serial and two-rank p=3-to-p=1 correctness artifacts +**PASSED** at `bee9e9d` on 1,158 fine and 98 coarse DoFs. They do not change the +executed `c3c1ded` scaling statuses above. + +An absorption shift alone is not a global correction. The 470k failure requires +a genuine coarse level. The next candidate is p-multigrid from the p=3 Nedelec +space to an assembled p=1 Nedelec coarse operator on the same mesh and physical +model. That two-level candidate has executed small correctness evidence, but +its registered 86k/471k shift sweep remains **NOT RUN**. HPDDM is **BLOCKED** in +the pinned image because PETSc lacks its HPDDM backend; it is not an equivalent +fallback. Do not infer scalability from configuration or the small canaries. + +The capability probe is part of `/opt/scatter3d/runtime-metadata.json` in every +new project image. For the pinned base digest, this exact probe reports both +values as `false`: + +```bash +python3 -c 'from petsc4py import PETSc; print({name: bool(PETSc.Sys.hasExternalPackage(name)) for name in ("hpddm", "slepc")})' +# {'hpddm': False, 'slepc': False} +``` + +The image build records `petsc_has_hpddm` and `petsc_has_slepc`, and the scaling +registration hashes that runtime metadata. This makes **BLOCKED** an auditable +capability result rather than an assumption. + Record setup versus solve time separately. Report global complex degrees of freedom, matrix nonzeros/estimated memory, and per-rank RSS high-water max and -sum. Do not claim a memory reduction or million-DoF capacity from configuration -alone; publish the executed benchmark artifact. +sum, plus cgroup `memory.peak` where available. Do not claim a memory reduction +or million-DoF capacity from configuration alone; publish the executed benchmark +artifact. Retain every registered shift/coarse-space attempt, including +failures, rather than publishing only the best parameter choice. ## Parallel scaling protocol @@ -275,4 +325,4 @@ controls pass. At minimum, archive: | Scaling | ranks/time/memory table | | Experiment | repeat/null/known-target reports | -If a gate was not run, write **NOT RUN**. “Expected to work” is not evidence. +If a gate has no executed evidence, write **NOT RUN**. “Expected to work” is not evidence. diff --git a/docs/DATA_SCHEMA.md b/docs/DATA_SCHEMA.md index 17602f5..5d35645 100644 --- a/docs/DATA_SCHEMA.md +++ b/docs/DATA_SCHEMA.md @@ -30,8 +30,8 @@ NPZ files are loaded with `allow_pickle=False`. | Key | dtype and shape | Requirement | |---|---|---| | `schema_version` | scalar Unicode | exactly `scatter3d.measurement.v1` | -| `reference_s` | complex, `[Rr,A,F,P,P]` | reference repeats | -| `dut_s` | complex, `[Rd,A,F,P,P]` | DUT repeats | +| `reference_s` | complex128, `[Rr,A,F,P,P]` | reference repeats | +| `dut_s` | complex128, `[Rd,A,F,P,P]` | DUT repeats | | `frequencies_hz` | float64, `[F]` | finite, positive, strictly increasing | | `angles_deg` | float64, `[A]` | finite and strictly increasing | | `port_labels` | Unicode, `[P]` | nonempty and unique | @@ -41,6 +41,9 @@ may have different repeat counts but must share all measurement axes exactly. When their repeat counts are equal and at least two, equal repeat indices declare an acquisition pair for the default mean-differential noise estimate. No frequency interpolation or port relabelling occurs during load. +The listed dtypes are exact. In particular, complex64 measurement tensors, +integer coordinate arrays, byte-string labels, and object arrays are rejected +rather than promoted or decoded implicitly. Example: @@ -81,25 +84,57 @@ the precise sensitivity equation. These fields are not guessed by the CLI. ## Reconstruction NPZ: `scatter3d.reconstruction.v1` -The CLI writes: - -| Key | Meaning | -|---|---| -| `estimate` | complex permittivity-contrast vector | -| `selected_rank` | retained TSVD rank | -| `method` | fixed, GCV, discrepancy, or energy | -| `residual_norm` | `||A x - b||_2` on used rows | -| `relative_residual` | residual divided by `||b||_2` | -| `solve_residual_norm` | residual in the whitened solve space, or raw space when unwhitened | -| `channel_mode`, `row_order` | explicit row selection and canonical ordering | -| `whitening_used`, `whitening_reason` | whether/why paired-repeat whitening ran | -| `paired_repeats` | paired repeat count, or `-1` when unavailable | -| `noise_standard_deviation` | selected-row standard deviation of the mean differential | -| `noise_model_sha256` | hash of that standard-deviation vector | -| `noise_norm_used` | discrepancy target in solve space; NaN when not applicable | -| `bundle_sha256` | exact measurement NPZ hash | -| `sensitivity_sha256` | exact sensitivity NPZ hash | -| `row_indices` | canonical rows used | +The CLI writes the complete audit artifact below. NumPy scalar strings use a +Unicode dtype; hashes are lowercase SHA-256 strings. + +| Key | dtype and shape | Meaning | +|---|---|---| +| `schema_version` | scalar Unicode | exactly `scatter3d.reconstruction.v1` | +| `estimate` | complex128, `[V]` | reconstructed complex contrast vector | +| `status` | scalar Unicode | `PASSED`, or `FAILED` when a discrepancy target was unmet | +| `requested_rank` | scalar int64 | fixed rank, or `-1` when not requested | +| `selected_rank` | scalar int64 | retained TSVD rank; discrepancy/GCV may select rank zero | +| `available_rank` | scalar int64 | singular values strictly above the recorded numerical threshold | +| `method` | scalar Unicode | `fixed`, `gcv`, `discrepancy`, or `energy` | +| `channel_mode` | scalar Unicode | `all`, `transmission`, or `reflection` | +| `row_order` | scalar Unicode | `C:[angle,frequency,receiver,source]` | +| `residual_norm` | scalar float64 | `||A x - b||_2` on the selected unwhitened rows | +| `relative_residual` | scalar float64 | unwhitened residual divided by `||b||_2` | +| `solve_residual_norm` | scalar float64 | residual in whitened solve space, or raw space when unwhitened | +| `solution_norm` | scalar float64 | `||x||_2` | +| `selected_condition_number` | scalar float64 | retained-spectrum condition estimate; NaN when undefined | +| `singular_value_threshold` | scalar float64 | numerical-rank cutoff | +| `singular_values` | float64, `[min(M,V)]` | full compact singular-value spectrum returned by the SVD | +| `singular_values_sha256` | scalar Unicode | deterministic array hash of `singular_values` | +| `criterion_ranks` | int64, `[K]` | selector candidate ranks; GCV/discrepancy include rank zero | +| `criterion_values` | float64, `[K]` | residual, GCV, discrepancy-residual, or cumulative-energy curve | +| `criterion_sha256` | scalar Unicode | combined deterministic hash of ranks and criterion values | +| `selection_target_met` | scalar int8 | `-1` not applicable, `0` unmet, `1` met | +| `whitening_used` | scalar bool | whether paired-repeat diagonal whitening was applied | +| `whitening_reason` | scalar Unicode | explicit use, disablement, or unavailable-evidence reason | +| `paired_repeats` | scalar int64 | paired repeat count, or `-1` when unavailable | +| `noise_standard_deviation` | float64, `[M_used]` or `[0]` | selected-row standard deviation of the mean differential | +| `noise_model_sha256` | scalar Unicode | hash of that vector, or an empty string when unwhitened | +| `noise_norm_used` | scalar float64 | discrepancy target in solve space; NaN when not applicable | +| `noise_norm_basis` | scalar Unicode | `user_supplied`, `whitened_expected_rms_sqrt_rows`, or empty | +| `energy_fraction_used` | scalar float64 | requested/default fraction; NaN outside energy mode | +| `noise_relative_floor` | scalar float64 | registered relative variance floor | +| `noise_absolute_floor` | scalar float64 | registered absolute variance floor | +| `overwrite_requested` | scalar bool | whether the run explicitly authorized clobbering via `--force` | +| `bundle_sha256` | scalar Unicode | exact measurement NPZ content hash | +| `sensitivity_sha256` | scalar Unicode | exact sensitivity NPZ content hash | +| `row_indices` | int64, `[M_used]` | canonical measurement rows actually used | + +The automatic discrepancy target `sqrt(M_used)` is emitted only after paired +repeat whitening and is an expected RMS heuristic under `E|z_i|^2=1`, not a +confidence bound. If no candidate rank meets the target, the NPZ still records +the best available full-rank result but sets `status=FAILED` and +`selection_target_met=0`. The CLI exits `1` unless the caller explicitly uses +`--allow-unmet-discrepancy`; that override does not rewrite the status. + +Reconstruction and JSON outputs are created atomically and are no-clobber by +default. `--force` is required to replace an existing path, and the NPZ records +that request in `overwrite_requested`. Complex estimates are not silently converted to real. Interpret the imaginary part according to the time convention and material model used to build `A`. diff --git a/docs/MEASUREMENT_RUNBOOK.md b/docs/MEASUREMENT_RUNBOOK.md index 05cbc2f..2c30fc9 100644 --- a/docs/MEASUREMENT_RUNBOOK.md +++ b/docs/MEASUREMENT_RUNBOOK.md @@ -45,7 +45,10 @@ NIST's multiport work demonstrates that calibration and measurement errors are correlated and should be propagated rather than treated as independent scalar noise ([Jargon, Williams, and Sanders, 2019](https://www.nist.gov/publications/three-port-vector-network-analyzer-calibrations-using-nist-microwave-uncertainty)). Full covariance is useful only when the number and diversity of repeats support -a stable estimate; otherwise use a registered diagonal model. +a stable estimate; otherwise use a registered diagonal model. The current dense +covariance API is opt-in, limited by an explicit observation-count guard, and +rejects `repeat_count <= observation_count` because the sample covariance would +be rank-deficient. Production whitening remains the O(N) diagonal path. ## Acceptance quantities @@ -61,7 +64,8 @@ rho = d_target / sigma_combined `M` is the number of retained complex samples. Also compute these quantities per frequency and channel; one large reflection coefficient must not mask unusable -transmission rows. +transmission rows. `scatter3d diagnose` emits one record per +frequency/receiver/source channel as well as the aggregate values. The project pre-registers `rho >= 5` for the deliberately strong known target as a practical proceed gate. This is a project engineering threshold, not a @@ -184,11 +188,19 @@ independently. enough independent repeats for a stable, reviewed estimator. 3. Whiten `A` and `b` with the identical transform. 4. Use discrepancy selection when the whitened noise norm is known; otherwise - use GCV. Record alternatives as sensitivity analysis, not a beauty contest. + use GCV. If no explicit discrepancy target is supplied after paired-repeat + whitening, the CLI uses `sqrt(rows)` only as the expected RMS scale under + `E|z_i|^2=1`. It is not a confidence bound. An unmet target is `FAILED` and + returns nonzero unless the operator explicitly records + `--allow-unmet-discrepancy`; the result remains `FAILED` under that override. + Record alternatives as sensitivity analysis, not a beauty contest. 5. Reconstruct the motion null, twin-POM null, empty fixture, and known target with the exact same pipeline. -6. Report residual, rank, singular spectrum, image peak/location, volume-weighted - metrics for known truth, and all negative controls. +6. Archive the reconstruction NPZ with the full compact singular spectrum, + numerical-rank threshold, complete selector rank/criterion curve, status, + whitening/floor parameters, input hashes, and selected rows. Report residual, + rank, image peak/location, volume-weighted metrics for known truth, and all + negative controls. 7. Blind the intended target label/location during final parameter selection when feasible. @@ -213,3 +225,19 @@ Archive: Until that package exists and passes its registered gates, describe the project as a tested imaging framework—not as a successful real POM/PLA imager. + +## FEM port truth boundary + +The historical FEM surface-current excitation is only an uncalibrated weak-form +load. It has no matched termination, accepted-power normalization, circuit +reference, incident/outgoing decomposition, or S-parameter meaning and must not +be used to claim agreement with a VNA port. + +A matched single-mode TEM boundary and electric-mode power-normalization +software path **PASSED** its digest-pinned runtime tests. Independent +transmission-line validation, incident/outgoing magnetic modal extraction, +reciprocity, accepted-power checks, and calibrated physical S-parameters remain +**NOT RUN**. Real POM/PLA VNA reconstruction is **BLOCKED** because no accepted +raw measurement/control bundle exists. The 3,000,000-complex-DoF solve is +**NOT RUN**; the 50% same-problem memory target **FAILED** at 86,103 DoFs and is +**NOT RUN** at 3,000,000 DoFs. diff --git a/docs/REFERENCES.md b/docs/REFERENCES.md index ff587c2..7d5a440 100644 --- a/docs/REFERENCES.md +++ b/docs/REFERENCES.md @@ -7,17 +7,28 @@ reproduced a paper's hardware result; reproduced results are listed only in ## Imaging method and experiment context -- EuCAP 2025, *Three-Dimensional Microwave Imaging Using a Scattering-Parameter - Data Equation*: -- URSI EMTS 2025 follow-up paper: +- Alexandros Pallaris and Daniel Sjöberg, EuCAP 2025, *Microwave + Reconstruction of Fabrication Defects in Known Objects Using Scattering + Parameter Sensitivities*: +- Alexandros Pallaris and Daniel Sjöberg, URSI-B EMTS 2025, *3D Simulation Code + Using Parallel Processing for Microwave Reconstruction of Defects in Known + Objects From Scattering Parameters*: + - Fresnel Institute 3-D electromagnetic inverse-scattering database: - Oblique-illumination microwave tomography study: -The supplied EuCAP experiment is a synthetic demonstration. It does not, by -itself, validate transfer of a simulated sensitivity operator to a real VNA, -antennas, cables, fixtures, and rotating target. +The EuCAP DOI title above matches the authors' institutional publication +metadata. The URSI follow-up explicitly states that both `A` and `b` in its +reported 3-D reconstruction are taken from simulation and describes measured +S-parameters as the intended real setup. Neither citation, by itself, validates +transfer of a simulated sensitivity operator to a real VNA, antennas, cables, +fixtures, and rotating target. + +These papers motivate the scientific problem only. Scatter3D-Codex remains an +original Apache-2.0 clean-room implementation and does not copy or relicense the +authors' or any third party's source code. ## VNA calibration and data interchange @@ -65,3 +76,8 @@ large-problem research path is right-preconditioned FGMRES with a shifted Maxwell surrogate and a verified two-level coarse correction. Neither the 3,000,000-DoF target nor a 50% memory reduction may be claimed until the same-problem benchmark in `CONVERGENCE_PROTOCOL.md` passes. + +The listed solver references do not validate the repository's current FEM port +path. The historical surface-current load is uncalibrated, and matched TEM work +remains in progress until its heavy tests and incident/outgoing modal extraction +pass. diff --git a/docs/SCALING_EVIDENCE.md b/docs/SCALING_EVIDENCE.md new file mode 100644 index 0000000..4e33650 --- /dev/null +++ b/docs/SCALING_EVIDENCE.md @@ -0,0 +1,242 @@ +# Distributed solver scaling evidence + +This page records executed solver results without upgrading a near miss into a +pass. The registered algebraic acceptance condition is a positive PETSc +convergence reason and a recomputed true relative residual no greater than +`1e-7` for both independent port right-hand sides. + +## Historical one-level evidence identity + +| Item | Value | +|---|---| +| Post-fix Git revision | `c3c1ded041fc6f8bcf768db8a0acefc65647bb7d` | +| Earlier baseline revision | `c4e1c3a80952175822cf19ff341fa99a2ff6e244` | +| Base image | `dolfinx/dolfinx:v0.10.0@sha256:f7cce2a2271bf838c080751348c471064acb41fef0330e2c08178a688f71890d` | +| Project image digest | `sha256:22cdddd5fd74259c8ffe27543d831a575787272af31a0ca5c6a1867ace88435d` | +| DOLFINx | `0.10.0` | +| PETSc / petsc4py | `3.24.0` | +| PETSc scalar | `complex128` | +| MPI | MPICH `4.3.1` | +| Exact historical CI | [GitHub Actions run 29207223783](https://github.com/HunterSpence/Scatter3D-Codex/actions/runs/29207223783) | + +The later small two-level correctness evidence has a separate identity and does +not replace the historical scaling artifacts above: + +| Item | Value | +|---|---| +| Git revision | `bee9e9d3628cc72ef0de2bda69f902629a057e24` | +| Project image ID | `sha256:2793b3d41da9ddba0d3f2838c6d3d22f7fdf2e66b1f8090548301d49fecdf66e` | +| Base image digest | `sha256:f7cce2a2271bf838c080751348c471064acb41fef0330e2c08178a688f71890d` | +| Exact CI | [GitHub Actions run 29212215039](https://github.com/HunterSpence/Scatter3D-Codex/actions/runs/29212215039) | +| Serial pMG JSON | `fem-smoke-pmg-serial-p3-p1.json`; SHA-256 `b364be215ad0a42a5788aa471305a119f3eb3717afafef0709e37eb88f07d284` | +| MPI2 pMG JSON | `fem-smoke-pmg-mpi2-p3-p1.json`; SHA-256 `f0a93d119de6b45f9a6093998c9216a556dfad64d8515e211657d1d5d44b3c61` | + +## Registered p-multigrid campaign preparation + +Campaign-03 attempted to prepare one disposable runner from exact green source +`5393e4494a5f63bdca24defba18eb7af7ffa5bf4` (Actions run +[`29222225396`](https://github.com/HunterSpence/Scatter3D-Codex/actions/runs/29222225396), +**PASSED**). Cryptographic runner attestation and bootstrap **PASSED**. The +preparation script then **FAILED** before source checkout because GNU `df` +rejects combining `-P` with `--output=avail`. + +No project image was built, neither lifecycle nor wrong-DoF canary ran, no +immutable registration was created, and no FEM process launched. Therefore all +eight registered p-multigrid entries remain **NOT RUN**. This preparation +failure is not a numerical result and supplies no scaling or memory evidence. + +The exact attempt-owned server, Primary IP, firewall, and ephemeral SSH key were +deleted; controller state recorded repeated exact absence over the deletion +window and an independent live inventory check **PASSED**. The public-safe +evidence is +[`campaign-03-preparation-failure.json`](evidence/campaign-03-preparation-failure.json). +The public JSON has SHA-256 +`64119bcff1a784bb26e319fe47194087a274bde123d187ae8c2cd288824937d6`. +Its canonical private archive manifest has SHA-256 +`77d6a44e09e0876500849c507122d3b7f926b285c235affc52f79013b6383e42`. +Infrastructure identities and raw provider records remain private. + +The portable capacity helper added for the next campaign has no shell or `df` +dependency. After cloning and detach-checking out the exact green commit, a +future identity-bound control pack must supply the resolved campaign and +Docker-root paths and preserve its JSON before image build or other +disk-intensive work. The example assumes `$docker_root` was resolved from the +live Docker runtime and `$PRIVATE_DIR` is a pre-created private evidence +directory: + +```bash +python3 validation/remote_capacity_preflight.py \ + --path campaign=/opt \ + --path docker="$docker_root" \ + --minimum-free-bytes 107374182400 \ + > "$PRIVATE_DIR/disk-capacity-contract.json" +``` + +That example is not campaign-03 evidence and has not yet passed the mandatory +live runner canary. + +## Direct-solver memory candidates — NOT RUN + +Two upstream developments are credible candidates for a separately registered +memory campaign, but neither is evidence for the current release: + +- [PETSc 3.25](https://petsc.org/release/changes/325/) adds MUMPS + `-pc_precision ` and an out-of-core temporary-directory + control. The [official PETSc 3.25.3 package + recipe](https://gitlab.com/petsc/petsc/-/raw/v3.25.3/config/BuildSystem/config/packages/MUMPS.py) + still pins MUMPS 5.8.2 and enables mixed precision only when the required + single- and double-precision MUMPS libraries are actually present. +- [MUMPS 5.9.0](https://mumps-solver.org/index.php?page=dwnld) adds the + experimental adaptive-precision BLR control `ICNTL(40)` and a + single-precision factorization in a double-precision instance via + `ICNTL(47)`. Its manual describes up to seven custom formats; this project + does not infer a blanket IEEE-FP16 storage claim from that description. + +The pinned release runtime remains DOLFINx 0.10.0 with PETSc/petsc4py 3.24.0, +so both candidates are **NOT RUN** here. Testing either path requires a new +digest-pinned image, explicit build-feature and effective-option evidence, +double-precision residual/output comparisons, and a new immutable campaign +identity. Results from that runtime must not be spliced into the current pMG +sweep. + +MUMPS `INFOG(21)` and `INFOG(22)` remain useful secondary diagnostics: the +[MUMPS 5.9.0 manual](https://mumps-solver.org/doc/userguide_5.9.0.pdf) defines +them as effective memory used during factorization, respectively the maximum +and sum over processors. They are not total process or host high-water marks. +The project's at-most-50% target therefore continues to require identical- +problem cgroup-v2 `memory.peak` (or equivalent scheduler high-water) evidence +for both converged solvers. + +The historical c3 scaling JSON files do not retain the command line, Git +revision, dirty state, or image digest. The final server log archive retains `/usr/bin/time`'s verbatim +`Command being timed` records. Those commands are reproduced below. Future JSON +schemas should record the same provenance directly. + +## Exact benchmark commands + +The 470,928-DoF post-fix run, from +`p3-n16-mpi8-right-asm1-lu.log`: + +```bash +docker run --rm --ipc=host --memory=28g --memory-swap=28g --volume /opt/scatter3d-codex/artifacts/c4e1c3a:/artifacts scatter3d-codex:bench-c3 mpirun -n 8 python3 validation/fem_smoke.py --solver iterative --degree 3 --subdivisions 16 --frequencies-hz 1.0e8 --maximum-iterations 1000 --gmres-restart 100 --asm-overlap 1 --iterative-local-pc lu --minimum-global-dofs 450000 --output /artifacts/p3-n16-mpi8-right-asm1-lu.json +``` + +The identical direct post-fix reference, from `p3-n9-mpi4-direct.log`: + +```bash +docker run --rm --ipc=host --volume /opt/scatter3d-codex/artifacts/c4e1c3a:/artifacts scatter3d-codex:bench-c3 mpirun -n 4 python3 validation/fem_smoke.py --solver direct --degree 3 --subdivisions 9 --frequencies-hz 1.0e8 --output /artifacts/p3-n9-mpi4-direct.json +``` + +The earlier one-level ILU baseline, from +`p3-n9-mpi4-right-asm1-ilu.log`: + +```bash +docker run --rm --ipc=host --volume /opt/scatter3d-codex/artifacts/c4e1c3a:/artifacts scatter3d-codex:bench-c4 mpirun -n 4 python3 validation/fem_smoke.py --solver iterative --degree 3 --subdivisions 9 --frequencies-hz 1.0e8 --maximum-iterations 1000 --gmres-restart 80 --asm-overlap 1 --iterative-local-pc ilu --output /artifacts/p3-n9-mpi4-right-asm1-ilu.json +``` + +The corrected post-fix local-MUMPS run, from +`p3-n9-mpi4-right-asm1-lu-fixed.log`: + +```bash +docker run --rm --ipc=host --volume /opt/scatter3d-codex/artifacts/c4e1c3a:/artifacts scatter3d-codex:bench-c3 mpirun -n 4 python3 validation/fem_smoke.py --solver iterative --degree 3 --subdivisions 9 --frequencies-hz 1.0e8 --maximum-iterations 1000 --gmres-restart 80 --asm-overlap 1 --iterative-local-pc lu --output /artifacts/p3-n9-mpi4-right-asm1-lu-fixed.json +``` + +The pre-fix supposed-LU canary, from +`p3-n9-mpi4-right-asm1-lu.log`: + +```bash +docker run --rm --ipc=host --volume /opt/scatter3d-codex/artifacts/c4e1c3a:/artifacts scatter3d-codex:bench-c4 mpirun -n 4 python3 validation/fem_smoke.py --solver iterative --degree 3 --subdivisions 9 --frequencies-hz 1.0e8 --maximum-iterations 1000 --gmres-restart 80 --asm-overlap 1 --iterative-local-pc lu --output /artifacts/p3-n9-mpi4-right-asm1-lu.json +``` + +That final command requested LU, but the pre-fix lifecycle bug prevented the +nested PC from consuming the request; the effective local PC was ILU. The +command therefore documents intent, not effective LU evidence. The exact +standalone command that rendered the memory-comparison JSON was not present in +the retained `Command being timed` records; its two input runs and comparison +values are retained in the JSON. + +## Executed scaling ladder + +| Gate | Problem and solver | Result | Evidence | +|---|---|---|---| +| One-level ASM/ILU baseline | p=3, subdivisions 9, 4 MPI ranks, 100 MHz, 86,103 global complex DoFs, 7,038,009 nonzeros, right FGMRES restart 80, ASM overlap 1, local ILU(0) | **FAILED** | Both RHS reached 1,000 iterations; best recorded true relative residual was approximately `4.93e-3` | +| Corrected one-level ASM/local-MUMPS | Same p=3 problem, right FGMRES restart 80, ASM overlap 1, local preonly/LU/MUMPS | **PASSED** | 233 and 230 iterations; true relative residuals `9.3958e-9` and `9.9821e-9` | +| Identical direct MUMPS reference | Same p=3 problem and 4 MPI ranks, direct preonly/LU/MUMPS with two RHS | **PASSED** | True relative residuals `9.59e-14` and `9.83e-14` | +| Same-problem memory gate | Iterative versus direct at 86,103 DoFs; metric is sum of rank process high-water RSS bytes | **FAILED** | Iterative `2,544,521,216`; direct `3,079,335,936`; ratio `0.8263214111`, above the required `0.5` | +| Next scaling rung | p=3, subdivisions 16, 8 MPI ranks, 100 MHz, 470,928 global complex DoFs, 39,290,544 nonzeros, right FGMRES restart 100, ASM overlap 1, local LU/MUMPS | **FAILED** | Both RHS reached 1,000 iterations; true relative residuals `2.4996e-7` and `5.5071e-6` | +| At least 3,000,000 global complex DoFs | Two RHS with the registered true-residual gate | **NOT RUN** | No executed artifact exists | +| Real POM/PLA VNA reconstruction | Accepted raw reference, DUT, null, known-target, calibration, coordinates, geometry, materials, and protocol bundle | **BLOCKED** | No accepted raw measurement bundle has been supplied | + +The 470,928-DoF run assembled in about `17.89` seconds and did not exhaust its +28 GiB cgroup limit. Its summed rank peak RSS was `13,988,892,672` bytes and its +maximum single-rank peak RSS was `1,946,419,200` bytes. Available memory was not +the acceptance criterion: the algebraic gate **FAILED**. + +Process high-water RSS is useful diagnostic evidence, but scheduler or cgroup +`memory.peak` is preferred for a publication-grade memory claim. No memory ratio +at 3,000,000 DoFs has been measured. + +## PETSc nested-options correction + +Before `c3c1ded`, temporary prefixed PETSc options were removed immediately after +`KSPSetFromOptions` and before `KSPSetUp`. ASM creates and configures its nested +subdomain KSP and PC objects during setup, so the requested `sub_pc_type=lu` was +not consumed; PETSc retained its default ILU. Early ILU and supposed-LU results +were therefore identical because both used ILU. + +Revision `c3c1ded` keeps the prefixed options installed through `KSPSetUp`, then +removes them in a `finally` block. A heavy regression requests an invalid nested +PC and proves that setup consumes the nested option. A live `-ksp_view` in the +pinned image then confirmed ASM with subdomain `preonly`, LU, and MUMPS. The +post-fix `p3-n9-mpi4-right-asm1-lu-fixed.json` is the admissible local-LU +artifact. The pre-fix `p3-n9-mpi4-right-asm1-lu.json` is **FAILED** as local-LU +evidence and must not be used for that claim. + +## Artifact SHA-256 manifest + +These hashes were recomputed from the local evidence mirror rather than copied +from filenames or terminal prose. + +| Artifact | SHA-256 | Interpretation | +|---|---|---| +| `p3-n16-mpi8-right-asm1-lu.json` | `e0f0e40bd1d1b100645087da3bbe22821682afc939455d0e16fa51957bdbddb7` | 470,928-DoF **FAILED** rung | +| `p3-n9-mpi4-direct.json` | `00be6c3560c39bb4f23b32409c5ff9a7c94298fa7b73c5e9b3fe9f5072fe4196` | Identical direct **PASSED** reference | +| `p3-n9-mpi4-memory-gate.json` | `5caf2f39e05106307d1c11aab4ac55eb80fb347d6b9c97f25cf8e72010d8f3ab` | Same-problem memory **FAILED** gate | +| `p3-n9-mpi4-right-asm1-ilu.json` | `da385b3cc910970f97b1f81dd7cb9d4f1fb1cb3f38a256f5305bccda319ae2a2` | One-level ILU **FAILED** baseline | +| `p3-n9-mpi4-right-asm1-lu-fixed.json` | `f7e7b1ffbb2830a0b80089ad58699b93db101e24cbbfb445a615c7e6ca2b0a89` | Corrected local-MUMPS **PASSED** run | +| `p3-n9-mpi4-right-asm1-lu.json` | `bf437d5ac34ce1418a654c454628b5657b3866f0282b15882f1f988f74ceb362` | Pre-fix non-LU canary; **FAILED** as LU evidence | + +The JSON files are numerical release evidence, not source code. They remain +outside the Git tree under the repository's artifact-ignore policy and must be +attached with a SHA-256 manifest to any release that cites them. + +## Required next solver step + +The 86k pass followed by the 471k failure demonstrates that one-level ASM with +exact local solves is not mesh-scalable. Raising the iteration cap alone is not +the next acceptance step. The current development source now keeps the physical +Maxwell matrix as `A`, can assemble a separate absorption-shifted Maxwell matrix +as `P`, and calls `KSPSetOperators(A, P)`. It also preserves requested and +setup-observed PETSc hierarchy, the raw ASCII KSP view, and validation-v2 +provenance. Small serial and two-rank p=3-to-p=1 correctness artifacts +**PASSED** at `bee9e9d` with 1,158 fine and 98 coarse DoFs; they do not modify +the historical scaling results in this file. + +The p=3-to-p=1 Nedelec p-multigrid implementation is now present and its small +correctness cases **PASSED**. Its registered 86k/471k shift sweep remains +**NOT RUN**. Every registered shift, including failures, must be retained; no +scaling claim is made from the small CI cases. + +The immutable experiment is +[`validation/scaling_sweep_v1.json`](../validation/scaling_sweep_v1.json). +`validation/register_scaling_sweep.py` must write its complete registration +outside the clean source tree before the first solve. +`validation/run_registered_scaling_sweep.py --run-id ` executes +one entry without clobbering prior evidence. A non-passing entry stops automatic +execution; continuing with an unstarted ID requires an explicit reviewed finding +that the completed failure was numerical and the evidence system remains safe. +Each run records separate stdout/stderr, `fem-smoke.json`, `exit-code.json`, and +`SHA256SUMS`; the executor binds the complete physical problem, exact DoFs, +source, images, runtime, 28 GiB no-swap cgroup, host `memory.peak`, and the +registered 10,800-second wall-time cap. The exact public command sequence is in +the [README](../README.md#immutable-remote-scaling-sweep). diff --git a/docs/VERIFICATION.md b/docs/VERIFICATION.md index c248a38..f30773c 100644 --- a/docs/VERIFICATION.md +++ b/docs/VERIFICATION.md @@ -10,14 +10,158 @@ not replace CI logs or experiment artifacts. | Job | Environment | What it may establish | |---|---|---| -| `pure-python` | CPython 3.11 and 3.12 | array contracts, CSV round trips, hashes, reference-only alignment, repeat noise, whitening, TSVD, metrics, NPZ pipeline | -| `heavy-dolfinx` | digest-pinned complex DOLFINx/PETSc | form assembly, PML tensor/form behavior, port normalization, same-matrix RHS lifecycle, checkpoint identity, small FEM smoke | -| `mpi` | same image, exactly two ranks | distributed ownership/reduction behavior and two-rank solver smoke | +| `pure-python` | CPython 3.11, 3.12, 3.13, and 3.14 | array contracts, CSV round trips, hashes, reference-only alignment, repeat noise, whitening, TSVD, metrics, NPZ pipeline, and pure FEM configuration/tag/checkpoint contracts | +| `heavy-dolfinx` | digest-pinned complex DOLFINx/PETSc | heavy tests, manufactured H(curl) p=1/2/3, and a direct p=3 repeated-RHS smoke solve | +| `mpi` | same image, exactly two ranks | MPI tests plus a small distributed iterative repeated-RHS solve and its ownership-independent global DoF count | Heavy and MPI jobs fail if zero tests are collected or any selected test skips. This prevents an unavailable DOLFINx dependency from producing a misleading green check. +These jobs archive JSON evidence, but do not establish PML reflection +performance, a calibrated physical/S-parameter port, analytical scattering +accuracy, production scaling, or checkpoint/restart behavior. + +## Identified automated evidence + +[GitHub Actions run +29207223783](https://github.com/HunterSpence/Scatter3D-Codex/actions/runs/29207223783) +executed revision `c3c1ded041fc6f8bcf768db8a0acefc65647bb7d` and all configured +repository/static/package, CPython 3.11–3.14, complex DOLFINx, and two-rank MPI +jobs **PASSED**. Remote exact-revision verification also recorded 109 non-heavy +tests and 11 heavy non-MPI tests with warnings treated as errors and zero heavy +skips. + +[GitHub Actions run +29206335149](https://github.com/HunterSpence/Scatter3D-Codex/actions/runs/29206335149) +retains the earlier numeric manufactured-solution and solver artifacts from +`ad9a43b`. Those results remain applicable where the exercised code was +unchanged by the later PETSc option-lifecycle correction. + +- **PASSED:** 109 pure tests on each of CPython 3.11, 3.12, 3.13, and 3.14; + wheel/sdist build, clean installs, CLI checks, static checks, link validation, + CFF validation, and redacted secret scans. +- **PASSED:** 11 DOLFINx-heavy tests with zero skips at `c3c1ded` in DOLFINx + 0.10.0 and complex128 PETSc 3.24.0. The added regression proves nested ASM + options remain installed until setup consumes them. +- **PASSED:** manufactured H(curl) convergence over subdivisions 3, 4, and 6. + Observed orders were 0.913/0.958 for p=1, 1.902/1.950 for p=2, and + 2.948/2.983 for p=3. The finest p=3 system had 26,298 global complex DoFs; + all recorded true relative residuals were below `3.1e-13`. +- **PASSED:** a direct p=3, 1,158-DoF repeated-RHS smoke solve. It recorded one + matrix assembly, one operator setup/factorization, two RHS solves, and true + relative residuals below `9.1e-15`. +- **PASSED:** two-rank MPI test with zero skips and a 98-DoF iterative + repeated-RHS smoke solve. It recorded zero global factorizations, 13 and 14 + FGMRES iterations, and true relative residuals `6.76e-9` and `1.97e-9`. + +The 98-DoF MPI result is a correctness canary only. It is not evidence of +parallel efficiency, million-DoF capacity, or a memory advantage. + +GitHub Actions run +[29212215039](https://github.com/HunterSpence/Scatter3D-Codex/actions/runs/29212215039) +at `bee9e9d3628cc72ef0de2bda69f902629a057e24` added exact-revision two-level +p=3-to-p=1 p-multigrid correctness evidence: + +- **PASSED:** repository/static/package gates, clean wheel/sdist installs, + CPython 3.11–3.14 pure jobs, 20 complex DOLFINx-heavy tests with zero skips, + and the selected two-rank MPI tests with zero skips. + +- **PASSED:** serial, 1,158 fine and 98 coarse complex DoFs, shift `0.5`, two + RHS in 6 and 6 iterations, with true relative residuals `1.615e-10` and + `1.417e-10`. +- **PASSED:** two MPI ranks on the identical discrete size, two RHS in 10 and + 10 iterations, with true relative residuals `1.246e-9` and `1.298e-9`. +- **PASSED:** both artifacts record `PCUseAmat=false`, one-step + Richardson/ASM fine smoothing, local MUMPS LU, a global p=1 MUMPS coarse + solve, PEC-masked transfer, and live outer/fine/coarse operator identities. +- **PASSED:** both artifact SHA-256 manifests verified after download. The + serial p-multigrid JSON hash is + `b364be215ad0a42a5788aa471305a119f3eb3717afafef0709e37eb88f07d284` + and the MPI JSON hash is + `f0a93d119de6b45f9a6093998c9216a556dfad64d8515e211657d1d5d44b3c61`. + +| Artifact | Exact command inside the pinned image | Actions artifact path | SHA-256 | +|---|---|---|---| +| Serial pMG | `python3 validation/fem_smoke.py --solver iterative --iterative-hierarchy p-multigrid --p-multigrid-coarse-degree 1 --preconditioner-absorption-shift 0.5 --iterative-local-pc lu --degree 3 --subdivisions 2 --frequencies-hz 1.0e8 --output /artifacts/fem-smoke-pmg-serial-p3-p1.json` | `scatter3d-heavy-verification-bee9e9d3628cc72ef0de2bda69f902629a057e24/fem-smoke-pmg-serial-p3-p1.json` | `b364be215ad0a42a5788aa471305a119f3eb3717afafef0709e37eb88f07d284` | +| MPI2 pMG | `mpirun -n 2 python3 validation/fem_smoke.py --solver iterative --iterative-hierarchy p-multigrid --p-multigrid-coarse-degree 1 --preconditioner-absorption-shift 0.5 --iterative-local-pc lu --degree 3 --subdivisions 2 --frequencies-hz 1.0e8 --output /artifacts/fem-smoke-pmg-mpi2-p3-p1.json` | `scatter3d-mpi-verification-bee9e9d3628cc72ef0de2bda69f902629a057e24/fem-smoke-pmg-mpi2-p3-p1.json` | `f0a93d119de6b45f9a6093998c9216a556dfad64d8515e211657d1d5d44b3c61` | + +Both used project image ID +`sha256:2793b3d41da9ddba0d3f2838c6d3d22f7fdf2e66b1f8090548301d49fecdf66e`, +base digest +`sha256:f7cce2a2271bf838c080751348c471064acb41fef0330e2c08178a688f71890d`, +DOLFINx 0.10.0, PETSc/petsc4py 3.24.0 complex128, and MPICH 4.3.1. Actions +artifacts expire; any release citing these results must attach their JSON and +`SHA256SUMS` files. + +These are small correctness cases, not p-multigrid scaling evidence. + +Separate remote scaling runs at `c3c1ded` established the following: + +- **PASSED:** p=3 at 86,103 global complex DoFs with right FGMRES, ASM overlap + 1, and local MUMPS LU. Two RHS converged in 233 and 230 iterations with true + relative residuals `9.3958e-9` and `9.9821e-9`. +- **PASSED:** the identical direct MUMPS reference at 86,103 DoFs, with true + relative residuals `9.59e-14` and `9.83e-14`. +- **FAILED:** peak memory at most 50% of direct on that identical problem. The + summed rank process high-water RSS values were `2,544,521,216` iterative and + `3,079,335,936` direct bytes, a ratio of `0.8263214111`. +- **FAILED:** p=3 at 470,928 global complex DoFs. Both RHS reached 1,000 + iterations; true relative residuals were `2.4996e-7` and `5.5071e-6`. + +The exact environment, artifact hashes, option-lifecycle correction, and command +provenance limitation are recorded in [Distributed solver scaling +evidence](SCALING_EVIDENCE.md). + +## Current development capability boundary + +Source changes after `c3c1ded` now implement a separate optional +absorption-shifted preconditioning matrix `P` and call +`KSPSetOperators(A, P)` while preserving the physical matrix `A`, right-hand +sides, and true-residual calculation. Zero shift reuses `A` as `P` without a +second matrix assembly. + +The solver now captures requested and effective PETSc configuration after +setup. It validates top-level types, factor backend, side, tolerances, and +iteration cap; aggregates live ASM subdomain solvers across ranks; parses ASM +type and overlap from the PETSc ASCII view; and retains that raw view. FEM +validation schema `scatter3d.validation.fem_smoke/v2` adds source/command/image, +runtime, cgroup, physical-problem, and requested/effective solver provenance, +with atomic no-clobber output unless `--overwrite` is explicit. + +The exact-revision serial and MPI correctness artifacts above validate the +two-level software hierarchy at 1,158 fine DoFs. The registered shift sweep at +86,103 and 470,928 DoFs is **NOT RUN**. All historical `c3c1ded` +**PASSED**/**FAILED** scaling and memory results above are unchanged. + +Campaign-03 reached a disposable runner with attestation and bootstrap +**PASSED**, but remote preparation **FAILED** before source checkout because a +disk-capacity check used mutually incompatible GNU `df` options. The image +build, lifecycle canary, wrong-DoF canary, immutable registration, and all eight +solver runs are **NOT RUN**. Attempt-owned provider resources and the ephemeral +key were deleted and independently verified absent. See the +[sanitized failure record](evidence/campaign-03-preparation-failure.json). + +## Current truth boundary + +- The old FEM surface-current RHS is an explicitly uncalibrated load, not a + matched physical port and not an S-parameter source or receiver. +- Matched single-mode TEM forms and electric-mode power normalization **PASSED** + their exact-commit software/runtime tests. Incident/outgoing magnetic modal + extraction, calibrated S-parameters, reciprocity, and an independent thru + benchmark are **NOT RUN**, so physical port calibration remains unverified. +- Real POM/PLA VNA reconstruction is **BLOCKED** because no accepted raw repeat, + null, known-target, calibration, coordinate, material, geometry, and protocol + bundle has been supplied. +- Convergence at 3,000,000 or more global complex DoFs is **NOT RUN**. +- Peak memory at or below 50% of direct on the identical 86,103-DoF problem is + **FAILED**. A same-problem comparison at 3,000,000 DoFs is **NOT RUN**; a ratio + measured only on a smaller common problem cannot establish the 3M target. + +Do not infer a pass from the presence of source or test files. A status becomes +`PASSED` only when the exact revision, command, environment, and artifact are +identified. + ## What automated tests do not establish Even when all jobs are green, the following remain unvalidated until their own @@ -26,7 +170,8 @@ artifacts are committed or archived with a release: - the actual four-antenna CAD/mesh and 5–7 GHz production problem; - a converged PML reflection, h/p/quadrature, sphere/waveguide, and sensitivity finite-difference campaign on that geometry; -- a production-scale distributed benchmark and memory claim; +- a distributed convergence result at 3,000,000 or more global complex DoFs; +- a same-problem memory ratio at 3,000,000 DoFs; - VNA calibration quality and cable/thermal stability; - measured POM and printed-PLA complex material properties; - stationary, motion, reseat, twin-POM, and known-target controls; @@ -40,13 +185,22 @@ must not be turned into a public success claim. Before tagging an experimental release, attach or archive: -- [ ] green pure, heavy, and MPI jobs on the release commit; +- [x] green pure, heavy, MPI, package, and static development CI at `bee9e9d`; +- [ ] green pure, heavy, MPI, package, and static CI on the final release + revision; - [ ] `git diff --check` and a clean signed/tagged revision; -- [ ] source distribution and wheel built from that revision; -- [ ] container digest and complex-PETSc assertion; -- [ ] manufactured/analytic/PML/discretization convergence report; +- [ ] source distribution and wheel built from the final release revision; +- [ ] final release container identity and complex-PETSc assertion; +- [ ] manufactured/analytic/PML/discretization convergence report (manufactured + p=1/2/3 **PASSED**; analytic scattering, PML reflection, and production + discretization remain **NOT RUN**); +- [ ] matched-port accepted-power, incident/outgoing modal extraction, + reciprocity, and independent transmission-line comparison; - [ ] sensitivity finite-difference and linearization-range report; -- [ ] production scaling/memory report; +- [x] 86,103- and 470,928-DoF scaling attempts and 86,103-DoF same-problem + memory comparison archived with honest **PASSED**/**FAILED** statuses; +- [ ] convergence at 3,000,000 or more global complex DoFs; +- [ ] same-problem memory ratio at 3,000,000 DoFs; - [ ] raw-input hashes and coordinate/manifests; - [ ] complete VNA calibration and independent verification record; - [ ] repeat/null/motion/known-target report; @@ -57,11 +211,18 @@ Before tagging an experimental release, attach or archive: Use these labels consistently: -- **implemented:** code exists and passes its unit contract; -- **tested:** the exact path executed in an identified environment; -- **verified:** compared with an analytic/independent numerical truth; -- **experimentally validated:** frozen pipeline passed registered physical - controls and truth metrics; -- **not run:** no executed evidence exists. +- **PASSED:** the registered gate completed successfully on an identified exact + revision and environment, with its artifact retained; +- **FAILED:** the gate ran and did not meet its registered criterion. For TSVD + discrepancy selection, the reconstruction artifact remains `FAILED` even if + `--allow-unmet-discrepancy` suppresses the nonzero exit; +- **NOT RUN:** no executed evidence exists for that exact gate and revision; +- **BLOCKED:** the gate could not run because a named prerequisite or resource + was unavailable; this is not a pass or a failure; +- **implemented:** code exists; this label alone says nothing about execution; +- **verified:** a `PASSED` result was compared with analytic or independent + numerical truth; +- **experimentally validated:** the frozen pipeline passed registered physical + controls and truth metrics. Never upgrade one label because a visually plausible image was produced. diff --git a/docs/evidence/SHA256SUMS b/docs/evidence/SHA256SUMS new file mode 100644 index 0000000..b8e0168 --- /dev/null +++ b/docs/evidence/SHA256SUMS @@ -0,0 +1 @@ +64119bcff1a784bb26e319fe47194087a274bde123d187ae8c2cd288824937d6 campaign-03-preparation-failure.json diff --git a/docs/evidence/campaign-03-preparation-failure.json b/docs/evidence/campaign-03-preparation-failure.json new file mode 100644 index 0000000..fc9df40 --- /dev/null +++ b/docs/evidence/campaign-03-preparation-failure.json @@ -0,0 +1,49 @@ +{ + "attempt": "campaign-03", + "cleanup": { + "ephemeral_ssh_key": "PASSED", + "firewall": "PASSED", + "independent_absence_check": "PASSED", + "primary_ip": "PASSED", + "server": "PASSED" + }, + "control": { + "build_id": "scatter3d-provider-control-v2.3-20260713-04", + "controller_sha256": "2ef0cb8cf036268b6e2e05cb006511f05de609389253f60ebcc08af197d6c8c4", + "preparation_script_sha256": "c98f62f8db0c6ee410b8b0e003af96be333c3d72c20514ab5c7345f3c975f521", + "transport_sha256": "41d5761dd4e4258ed6a53452f2094363bc83177361bdf3516e12f1bad10154fc" + }, + "failure": { + "message": "df: options -P and --output are mutually exclusive", + "phase": "remote_preparation", + "status": "FAILED" + }, + "gates": { + "bootstrap": "PASSED", + "image_build": "NOT RUN", + "immutable_registration": "NOT RUN", + "lifecycle_canary": "NOT RUN", + "p3_pmg_scaling_sweep": "NOT RUN", + "runner_attestation": "PASSED", + "source_checkout": "NOT RUN", + "wrong_dof_canary": "NOT RUN" + }, + "private_archive_hashes": { + "campaign_manifest_sha256": "77d6a44e09e0876500849c507122d3b7f926b285c235affc52f79013b6383e42", + "ephemeral_ssh_state_sha256": "1ec325d64a1b277d659cbf23a448248367929f6a254ea6b94170effe18125645", + "partial_preparation_tar_sha256": "8f4d3ded40f315df00a6b06f56fab8e427f820ad50c13f4a389aa60288bb44c1", + "provider_deleted_state_sha256": "0f587a296cf35c16074c6d802e8884967d531dd25ae178a0f65e17e3c086d906" + }, + "runner_contract": { + "location": "fsn1", + "server_type": "cpx62" + }, + "schema": "scatter3d.evidence.remote_preparation_failure/v1", + "solver_attempts": 0, + "source": { + "ci_run": 29222225396, + "ci_status": "PASSED", + "commit": "5393e4494a5f63bdca24defba18eb7af7ffa5bf4" + }, + "status": "FAILED" +} diff --git a/examples/run_container_verification.sh b/examples/run_container_verification.sh old mode 100644 new mode 100755 index bb3d896..1d90a3b --- a/examples/run_container_verification.sh +++ b/examples/run_container_verification.sh @@ -1,11 +1,51 @@ #!/usr/bin/env bash set -euo pipefail +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +repo_root=$(cd -- "${script_dir}/.." && pwd) +cd "${repo_root}" + image="${1:-scatter3d-codex:verify}" docker build --pull=false -f docker/Dockerfile -t "${image}" . -docker run --rm "${image}" python3 -m pytest -m "heavy and not mpi" -ra -docker run --rm --ipc=host "${image}" \ - mpirun -n 2 python3 -m pytest -m mpi -ra -echo "Container heavy and two-rank commands completed. Review test counts and CI skip gate." +heavy_command=$(cat <<'BASH' +python3 -m pytest -p no:cacheprovider -W error -m "heavy and not mpi" -ra --junitxml=/tmp/heavy.xml +python3 - <<'PY' +import xml.etree.ElementTree as ET + +root = ET.parse("/tmp/heavy.xml").getroot() +suites = [root] if root.tag == "testsuite" else list(root.findall("testsuite")) +tests = sum(int(suite.get("tests", 0)) for suite in suites) +skipped = sum(int(suite.get("skipped", 0)) for suite in suites) +if tests == 0 or skipped: + raise SystemExit(f"heavy verification invalid: tests={tests}, skipped={skipped}") +print(f"heavy verification complete: tests={tests}, skipped={skipped}") +PY +BASH +) +docker run --rm "${image}" bash -euc "${heavy_command}" + +mpi_command=$(cat <<'BASH' +mpirun -n 2 sh -euc ' + python3 -m pytest -p no:cacheprovider -W error -m mpi -ra --junitxml=/tmp/mpi-${OMPI_COMM_WORLD_RANK}.xml +' +python3 - <<'PY' +import glob +import xml.etree.ElementTree as ET + +files = glob.glob("/tmp/mpi-*.xml") +if len(files) != 2: + raise SystemExit(f"expected two MPI reports, found {files}") +for path in files: + root = ET.parse(path).getroot() + suites = [root] if root.tag == "testsuite" else list(root.findall("testsuite")) + tests = sum(int(suite.get("tests", 0)) for suite in suites) + skipped = sum(int(suite.get("skipped", 0)) for suite in suites) + if tests == 0 or skipped: + raise SystemExit(f"MPI verification invalid in {path}: tests={tests}, skipped={skipped}") +print("MPI verification complete on two ranks with no skips") +PY +BASH +) +docker run --rm --ipc=host "${image}" bash -euc "${mpi_command}" diff --git a/pyproject.toml b/pyproject.toml index 8bd562c..8da18a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ description = "Verification-first microwave differential imaging with complex DO readme = "README.md" requires-python = ">=3.11" license = "Apache-2.0" +license-files = ["LICENSE"] authors = [ { name = "Hunter Spence" }, ] @@ -23,11 +24,12 @@ keywords = [ classifiers = [ "Development Status :: 3 - Alpha", "Intended Audience :: Science/Research", - "License :: OSI Approved :: Apache Software License", "Operating System :: OS Independent", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Topic :: Scientific/Engineering :: Physics", ] dependencies = [ diff --git a/src/scatter3d/cli.py b/src/scatter3d/cli.py index 3810369..d66aef3 100644 --- a/src/scatter3d/cli.py +++ b/src/scatter3d/cli.py @@ -5,8 +5,8 @@ import argparse import json import sys +from collections.abc import Sequence from pathlib import Path -from typing import Sequence from .pipeline import ( diagnose_measurement_bundle, @@ -30,10 +30,12 @@ def _parser() -> argparse.ArgumentParser: validate = subparsers.add_parser("validate", help="validate a measurement NPZ bundle") validate.add_argument("bundle", type=Path) validate.add_argument("--json", dest="json_path", type=Path, help="also write a JSON report") + validate.add_argument("--force", action="store_true", help="overwrite an existing JSON report") diagnose = subparsers.add_parser("diagnose", help="measure repeat floor and basic consistency") diagnose.add_argument("bundle", type=Path) diagnose.add_argument("--json", dest="json_path", type=Path, help="also write a JSON report") + diagnose.add_argument("--force", action="store_true", help="overwrite an existing JSON report") invert = subparsers.add_parser("invert", help="run a coordinate-checked TSVD reconstruction") invert.add_argument("bundle", type=Path) @@ -42,8 +44,16 @@ def _parser() -> argparse.ArgumentParser: invert.add_argument("--channel-mode", choices=("all", "transmission", "reflection"), default="all") invert.add_argument("--method", choices=("fixed", "gcv", "discrepancy", "energy"), default="gcv") invert.add_argument("--rank", type=int, help="required for --method fixed") - invert.add_argument("--noise-norm", type=float, help="required for --method discrepancy") - invert.add_argument("--energy-fraction", type=float, default=0.999) + invert.add_argument( + "--noise-norm", + type=float, + help="optional solve-space target; whitened discrepancy defaults to sqrt(rows)", + ) + invert.add_argument( + "--energy-fraction", + type=float, + help="energy selector fraction (defaults to 0.999 only for --method energy)", + ) invert.add_argument( "--whitening", choices=("auto", "off", "required"), @@ -53,14 +63,24 @@ def _parser() -> argparse.ArgumentParser: invert.add_argument("--noise-relative-floor", type=float, default=1.0e-12) invert.add_argument("--noise-absolute-floor", type=float, default=0.0) invert.add_argument("--json", dest="json_path", type=Path, help="also write a JSON report") + invert.add_argument( + "--allow-unmet-discrepancy", + action="store_true", + help="return zero even when no TSVD rank meets the discrepancy target", + ) + invert.add_argument( + "--force", + action="store_true", + help="overwrite existing reconstruction and JSON outputs", + ) return parser -def _emit(report: object, json_path: Path | None) -> None: +def _emit(report: object, json_path: Path | None, *, overwrite: bool = False) -> None: payload = report.to_dict() if hasattr(report, "to_dict") else report print(json.dumps(payload, indent=2, sort_keys=True, allow_nan=False)) if json_path is not None: - write_json_report(report, json_path) + write_json_report(report, json_path, overwrite=overwrite) def main(argv: Sequence[str] | None = None) -> int: @@ -69,10 +89,26 @@ def main(argv: Sequence[str] | None = None) -> int: if args.command == "schema": _emit(schema_description(), None) elif args.command == "validate": - _emit(validate_measurement_bundle(args.bundle), args.json_path) + _emit( + validate_measurement_bundle(args.bundle), + args.json_path, + overwrite=args.force, + ) elif args.command == "diagnose": - _emit(diagnose_measurement_bundle(args.bundle), args.json_path) + _emit( + diagnose_measurement_bundle(args.bundle), + args.json_path, + overwrite=args.force, + ) elif args.command == "invert": + if args.allow_unmet_discrepancy and args.method != "discrepancy": + raise ValueError( + "--allow-unmet-discrepancy is valid only with --method discrepancy" + ) + if args.json_path is not None and args.json_path.exists() and not args.force: + raise FileExistsError( + f"refusing to overwrite existing report: {args.json_path.resolve()}" + ) report = reconstruct_from_bundle( args.bundle, args.sensitivity, @@ -85,8 +121,11 @@ def main(argv: Sequence[str] | None = None) -> int: whitening=args.whitening, noise_relative_floor=args.noise_relative_floor, noise_absolute_floor=args.noise_absolute_floor, + overwrite=args.force, ) - _emit(report, args.json_path) + _emit(report, args.json_path, overwrite=args.force) + if report.selection_target_met is False and not args.allow_unmet_discrepancy: + return 1 else: # pragma: no cover - argparse enforces the command set raise AssertionError(args.command) except (FileNotFoundError, OSError, KeyError, TypeError, ValueError) as exc: diff --git a/src/scatter3d/fem/__init__.py b/src/scatter3d/fem/__init__.py index abe5c06..dee59db 100644 --- a/src/scatter3d/fem/__init__.py +++ b/src/scatter3d/fem/__init__.py @@ -14,14 +14,32 @@ MaxwellProblemConfig, PMLConfig, ) -from .diagnostics import MaterialChange, compare_material_models +from .diagnostics import ( + EffectiveSolverHierarchy, + MaterialChange, + RequestedSolverHierarchy, + SolverComponentDiagnostics, + SolverHierarchyDiagnostics, + compare_material_models, + parse_petsc_asm_view, + parse_petsc_mg_view, + validate_effective_solver_hierarchy, +) from .gmsh_io import LoadedMesh, load_gmsh_mesh -from .ports import PortDefinition, PortExcitation, normalize_port_mode +from .ports import ( + MatchedTEMPortExcitation, + NormalizedPortMode, + PortDefinition, + UncalibratedSurfaceCurrentExcitation, + normalize_port_mode, + total_electric_modal_coefficient, +) from .solver import ( ExperimentSweepResult, FrequencyDiagnostics, MaxwellSweepSolver, SweepResult, + TransferOperatorDiagnostics, ) from .tags import ( BoundaryTagContract, @@ -34,27 +52,38 @@ __all__ = [ "BoundaryTagContract", "CheckpointIdentity", + "EffectiveSolverHierarchy", "ExperimentMaterials", "ExperimentSweepResult", "FrequencyDiagnostics", "LinearSolverConfig", "LoadedMesh", + "MatchedTEMPortExcitation", "Material", "MaterialChange", "MaterialMap", "MaxwellProblemConfig", "MaxwellSweepSolver", "MeshTagContract", + "NormalizedPortMode", "PMLConfig", "PortDefinition", - "PortExcitation", + "RequestedSolverHierarchy", + "SolverComponentDiagnostics", + "SolverHierarchyDiagnostics", "SweepResult", "TagContractError", + "TransferOperatorDiagnostics", + "UncalibratedSurfaceCurrentExcitation", "VolumeTagContract", "checkpoint_fingerprint", "compare_material_models", "load_gmsh_mesh", "normalize_port_mode", + "parse_petsc_asm_view", + "parse_petsc_mg_view", "sha256_file", + "total_electric_modal_coefficient", + "validate_effective_solver_hierarchy", "validate_mesh_tags", ] diff --git a/src/scatter3d/fem/checkpoints.py b/src/scatter3d/fem/checkpoints.py index b39c743..a1191f5 100644 --- a/src/scatter3d/fem/checkpoints.py +++ b/src/scatter3d/fem/checkpoints.py @@ -2,11 +2,15 @@ from __future__ import annotations +import json +import os +import tempfile +from collections.abc import Mapping, Sequence from dataclasses import dataclass from hashlib import sha256 -import json +from itertools import pairwise from pathlib import Path -from typing import Any, Mapping, Sequence +from typing import Any def sha256_file(path: str | Path, chunk_size: int = 1024 * 1024) -> str: @@ -43,7 +47,7 @@ def __post_init__(self) -> None: frequencies = tuple(float(v) for v in self.frequencies_hz) if not frequencies or any(v <= 0 for v in frequencies): raise ValueError("frequencies_hz must contain positive values") - if any(b <= a for a, b in zip(frequencies, frequencies[1:])): + if any(b <= a for a, b in pairwise(frequencies)): raise ValueError("frequencies_hz must be strictly increasing") if int(self.mpi_size) < 1: raise ValueError("mpi_size must be positive") @@ -78,11 +82,27 @@ def write_manifest(path: str | Path, identity: CheckpointIdentity) -> None: target = Path(path) target.parent.mkdir(parents=True, exist_ok=True) payload = identity.canonical() | {"fingerprint": checkpoint_fingerprint(identity)} - temporary = target.with_suffix(target.suffix + ".tmp") - temporary.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8") - temporary.replace(target) + temporary: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=target.parent, + prefix=f".{target.name}.", + suffix=".tmp", + delete=False, + ) as stream: + temporary = Path(stream.name) + stream.write(json.dumps(payload, indent=2, sort_keys=True) + "\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, target) + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) def verify_manifest(path: str | Path, identity: CheckpointIdentity) -> bool: payload = json.loads(Path(path).read_text(encoding="utf-8")) - return payload.get("fingerprint") == checkpoint_fingerprint(identity) + expected = identity.canonical() | {"fingerprint": checkpoint_fingerprint(identity)} + return payload == expected diff --git a/src/scatter3d/fem/coax.py b/src/scatter3d/fem/coax.py new file mode 100644 index 0000000..552c163 --- /dev/null +++ b/src/scatter3d/fem/coax.py @@ -0,0 +1,778 @@ +"""Analytical TEM coaxial-line oracle for inexpensive port/cable checks. + +The routines in this module use standard telegrapher-equation formulas and do +not depend on DOLFINx or PETSc. They are intended as a fast, independently +checkable gate before running a three-dimensional Maxwell solve. + +The model assumes a uniform, homogeneous, isotropic dielectric fill and +frequency-local per-unit-length parameters. It does not model connectors, +higher-order modes, radiation, conductor dispersion, or the three-dimensional +port-normalization integral; passing this oracle is therefore necessary but +not sufficient evidence for a finite-element port implementation. + +Conventions +----------- +Time dependence is ``exp(-j*omega*t)`` and a forward wave varies as +``exp(-gamma*z)``. The ABCD convention is +``[V_in, I_in] = [[A, B], [C, D]] [V_out, I_out]`` with ``I_out`` directed +toward the load. Consequently, a lossless short has +``Gamma_in = -exp(+2j*beta*length)``. +""" + +from __future__ import annotations + +import cmath +import math +from collections.abc import Sequence +from dataclasses import asdict, dataclass +from numbers import Integral, Real +from typing import Any + +import numpy as np + +# CODATA 2018 values. Keeping them local avoids a SciPy dependency in this +# lightweight analytical gate. +VACUUM_PERMITTIVITY_F_PER_M = 8.854_187_812_8e-12 +VACUUM_PERMEABILITY_H_PER_M = 1.256_637_062_12e-6 + + +def _finite_real(name: str, value: Any) -> float: + if isinstance(value, bool | np.bool_) or not isinstance(value, Real): + raise TypeError(f"{name} must be a real scalar") + try: + result = float(value) + except OverflowError as exc: + raise ValueError(f"{name} must be finite") from exc + if not math.isfinite(result): + raise ValueError(f"{name} must be finite") + return 0.0 if result == 0.0 else result + + +def _real(name: str, value: Any, *, minimum: float, strict: bool) -> float: + result = _finite_real(name, value) + invalid = result <= minimum if strict else result < minimum + if invalid: + relation = ">" if strict else ">=" + raise ValueError(f"{name} must be {relation} {minimum}") + return result + + +def _positive_real(name: str, value: Any) -> float: + return _real(name, value, minimum=0.0, strict=True) + + +def _nonnegative_real(name: str, value: Any) -> float: + return _real(name, value, minimum=0.0, strict=False) + + +def _finite_complex(name: str, value: Any) -> complex: + if isinstance(value, bool | np.bool_): + raise TypeError(f"{name} must be a complex scalar") + try: + result = complex(value) + except OverflowError as exc: + raise ValueError(f"{name} must have finite real and imaginary parts") from exc + except (TypeError, ValueError) as exc: + raise TypeError(f"{name} must be a complex scalar") from exc + if not (math.isfinite(result.real) and math.isfinite(result.imag)): + raise ValueError(f"{name} must have finite real and imaginary parts") + real = 0.0 if result.real == 0.0 else result.real + imag = 0.0 if result.imag == 0.0 else result.imag + return complex(real, imag) + + +@dataclass(frozen=True, slots=True) +class ComplexValue: + """JSON-safe representation of a complex scalar.""" + + real: float + imag: float + + def __post_init__(self) -> None: + object.__setattr__(self, "real", _finite_real("real", self.real)) + object.__setattr__(self, "imag", _finite_real("imag", self.imag)) + + @classmethod + def from_complex(cls, value: Any) -> ComplexValue: + finite = _finite_complex("complex value", value) + return cls(real=finite.real, imag=finite.imag) + + @property + def value(self) -> complex: + return complex(self.real, self.imag) + + def __complex__(self) -> complex: + return self.value + + def to_dict(self) -> dict[str, float]: + return {"real": self.real, "imag": self.imag} + + +@dataclass(frozen=True, slots=True) +class CoaxialCable: + """Uniform coax geometry and passive material data. + + ``loss_tangent`` and ``dielectric_conductivity_s_per_m`` are additive loss + mechanisms. ``series_resistance_ohm_per_m`` permits a measured or + independently estimated conductor-loss term without embedding a particular + skin-effect model in this oracle. + """ + + inner_radius_m: float + outer_radius_m: float + relative_permittivity: float = 1.0 + relative_permeability: float = 1.0 + loss_tangent: float = 0.0 + dielectric_conductivity_s_per_m: float = 0.0 + series_resistance_ohm_per_m: float = 0.0 + + def __post_init__(self) -> None: + normalized = { + "inner_radius_m": _positive_real("inner_radius_m", self.inner_radius_m), + "outer_radius_m": _positive_real("outer_radius_m", self.outer_radius_m), + "relative_permittivity": _positive_real( + "relative_permittivity", self.relative_permittivity + ), + "relative_permeability": _positive_real( + "relative_permeability", self.relative_permeability + ), + "loss_tangent": _nonnegative_real("loss_tangent", self.loss_tangent), + "dielectric_conductivity_s_per_m": _nonnegative_real( + "dielectric_conductivity_s_per_m", self.dielectric_conductivity_s_per_m + ), + "series_resistance_ohm_per_m": _nonnegative_real( + "series_resistance_ohm_per_m", self.series_resistance_ohm_per_m + ), + } + if normalized["outer_radius_m"] <= normalized["inner_radius_m"]: + raise ValueError("outer_radius_m must be greater than inner_radius_m") + for name, value in normalized.items(): + object.__setattr__(self, name, value) + + def to_dict(self) -> dict[str, float]: + return asdict(self) + + +@dataclass(frozen=True, slots=True) +class CoaxLineParameters: + """Per-unit-length quantities at one frequency, encoded for JSON output.""" + + frequency_hz: float + angular_frequency_rad_s: float + inductance_h_per_m: float + capacitance_f_per_m: float + series_resistance_ohm_per_m: float + shunt_conductance_s_per_m: float + propagation_constant_per_m: ComplexValue + characteristic_impedance_ohm: ComplexValue + + def __post_init__(self) -> None: + positive_fields = ( + "frequency_hz", + "angular_frequency_rad_s", + "inductance_h_per_m", + "capacitance_f_per_m", + ) + nonnegative_fields = ( + "series_resistance_ohm_per_m", + "shunt_conductance_s_per_m", + ) + for name in positive_fields: + object.__setattr__(self, name, _positive_real(name, getattr(self, name))) + for name in nonnegative_fields: + object.__setattr__(self, name, _nonnegative_real(name, getattr(self, name))) + if not math.isclose( + self.angular_frequency_rad_s, + math.tau * self.frequency_hz, + rel_tol=2.0e-15, + ): + raise ValueError("angular_frequency_rad_s is inconsistent with frequency_hz") + if not isinstance(self.propagation_constant_per_m, ComplexValue): + raise TypeError("propagation_constant_per_m must be a ComplexValue") + if not isinstance(self.characteristic_impedance_ohm, ComplexValue): + raise TypeError("characteristic_impedance_ohm must be a ComplexValue") + if self.gamma.real < 0.0 or self.gamma.imag >= 0.0: + raise ValueError( + "propagation constant must have nonnegative loss and negative " + "imaginary part for exp(-j*omega*t)" + ) + if self.z0.real <= 0.0: + raise ValueError("characteristic impedance must have positive real part") + + @property + def gamma(self) -> complex: + return self.propagation_constant_per_m.value + + @property + def z0(self) -> complex: + return self.characteristic_impedance_ohm.value + + @property + def attenuation_np_per_m(self) -> float: + return self.gamma.real + + @property + def phase_constant_rad_per_m(self) -> float: + return -self.gamma.imag + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +def coax_line_parameters(cable: CoaxialCable, frequency_hz: float) -> CoaxLineParameters: + """Return TEM telegrapher parameters for a uniform coaxial cable. + + The per-unit-length model is + + ``L = mu*ln(b/a)/(2*pi)``, ``C = 2*pi*epsilon/ln(b/a)``, + ``G = 2*pi*sigma/ln(b/a) + omega*C*tan(delta)``, + ``gamma = sqrt((R-j*omega*L)*(G-j*omega*C))``, and + ``Z0 = sqrt((R-j*omega*L)/(G-j*omega*C))``. + """ + + if not isinstance(cable, CoaxialCable): + raise TypeError("cable must be a CoaxialCable") + frequency = _positive_real("frequency_hz", frequency_hz) + omega = _positive_real("angular_frequency_rad_s", math.tau * frequency) + # log1p preserves adjacent representable radii; the difference-of-logs + # fallback avoids overflow when the finite radius ratio is enormous. + relative_gap = (cable.outer_radius_m - cable.inner_radius_m) / cable.inner_radius_m + if math.isfinite(relative_gap): + log_radius_ratio = math.log1p(relative_gap) + else: + log_radius_ratio = math.log(cable.outer_radius_m) - math.log(cable.inner_radius_m) + log_radius_ratio = _positive_real("log radius ratio", log_radius_ratio) + permeability = VACUUM_PERMEABILITY_H_PER_M * cable.relative_permeability + permittivity = VACUUM_PERMITTIVITY_F_PER_M * cable.relative_permittivity + inductance = _positive_real( + "inductance_h_per_m", permeability * log_radius_ratio / math.tau + ) + capacitance = _positive_real( + "capacitance_f_per_m", math.tau * permittivity / log_radius_ratio + ) + conductance = _nonnegative_real( + "shunt_conductance_s_per_m", + math.tau * cable.dielectric_conductivity_s_per_m / log_radius_ratio + + omega * capacitance * cable.loss_tangent, + ) + + series_reactance = _positive_real("series reactance per metre", omega * inductance) + shunt_susceptance = _positive_real("shunt susceptance per metre", omega * capacitance) + series_impedance = complex(cable.series_resistance_ohm_per_m, -series_reactance) + shunt_admittance = complex(conductance, -shunt_susceptance) + if cable.series_resistance_ohm_per_m == 0.0 and conductance == 0.0: + # Preserve the exact lossless signs and zeros instead of relying on + # cancellation between the real parts of two complex square roots. + inductance_root = math.sqrt(inductance) + capacitance_root = math.sqrt(capacitance) + gamma = _finite_complex( + "propagation constant", + complex(0.0, -omega * (inductance_root * capacitance_root)), + ) + z0 = _finite_complex( + "characteristic impedance", inductance_root / capacitance_root + ) + else: + # Taking the roots before multiplying/dividing avoids avoidable + # overflow in ZY and Z/Y while retaining the passive branches. + series_root = cmath.sqrt(series_impedance) + shunt_root = cmath.sqrt(shunt_admittance) + gamma = _finite_complex("propagation constant", series_root * shunt_root) + z0 = _finite_complex("characteristic impedance", series_root / shunt_root) + + # Choose the passive forward-wave branches. The principal square root + # already has these signs for passive R/L/G/C, but making the choice explicit + # prevents platform-dependent signed-zero surprises at the lossless limit. + if gamma.imag > 0.0: + gamma = -gamma + if gamma.real < 0.0: + # For passive R/L/G/C this can only be cancellation roundoff: both + # square-root factors have arguments in [-pi/4, 0]. + gamma = complex(0.0, gamma.imag) + if z0.real < 0.0: + z0 = -z0 + + return CoaxLineParameters( + frequency_hz=frequency, + angular_frequency_rad_s=omega, + inductance_h_per_m=inductance, + capacitance_f_per_m=capacitance, + series_resistance_ohm_per_m=cable.series_resistance_ohm_per_m, + shunt_conductance_s_per_m=conductance, + propagation_constant_per_m=ComplexValue.from_complex(gamma), + characteristic_impedance_ohm=ComplexValue.from_complex(z0), + ) + + +@dataclass(frozen=True, slots=True) +class ABCDMatrix: + """JSON-ready two-port transmission matrix.""" + + a: ComplexValue + b: ComplexValue + c: ComplexValue + d: ComplexValue + + def __post_init__(self) -> None: + for name in ("a", "b", "c", "d"): + if not isinstance(getattr(self, name), ComplexValue): + raise TypeError(f"{name} must be a ComplexValue") + + @classmethod + def from_complex(cls, a: Any, b: Any, c: Any, d: Any) -> ABCDMatrix: + return cls( + a=ComplexValue.from_complex(a), + b=ComplexValue.from_complex(b), + c=ComplexValue.from_complex(c), + d=ComplexValue.from_complex(d), + ) + + @classmethod + def identity(cls) -> ABCDMatrix: + return cls.from_complex(1.0, 0.0, 0.0, 1.0) + + @property + def A(self) -> complex: + return self.a.value + + @property + def B(self) -> complex: + return self.b.value + + @property + def C(self) -> complex: + return self.c.value + + @property + def D(self) -> complex: + return self.d.value + + @property + def determinant(self) -> complex: + return self.A * self.D - self.B * self.C + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +def uniform_line_abcd(parameters: CoaxLineParameters, length_m: float) -> ABCDMatrix: + """Return the ABCD matrix for one uniform line section.""" + + if not isinstance(parameters, CoaxLineParameters): + raise TypeError("parameters must be CoaxLineParameters") + length = _nonnegative_real("length_m", length_m) + try: + electrical_length = _finite_complex("electrical length", parameters.gamma * length) + hyperbolic_cosine = _finite_complex( + "ABCD hyperbolic cosine", cmath.cosh(electrical_length) + ) + hyperbolic_sine = _finite_complex( + "ABCD hyperbolic sine", cmath.sinh(electrical_length) + ) + except (OverflowError, ValueError) as exc: + raise ValueError("line length and loss produce a non-finite ABCD matrix") from exc + return ABCDMatrix.from_complex( + hyperbolic_cosine, + parameters.z0 * hyperbolic_sine, + hyperbolic_sine / parameters.z0, + hyperbolic_cosine, + ) + + +def cascade_abcd(*sections: ABCDMatrix) -> ABCDMatrix: + """Cascade one or more sections in physical source-to-load order.""" + + if not sections: + raise ValueError("at least one ABCD section is required") + result = ABCDMatrix.identity() + for index, section in enumerate(sections): + if not isinstance(section, ABCDMatrix): + raise TypeError(f"sections[{index}] must be an ABCDMatrix") + result = ABCDMatrix.from_complex( + result.A * section.A + result.B * section.C, + result.A * section.B + result.B * section.D, + result.C * section.A + result.D * section.C, + result.C * section.B + result.D * section.D, + ) + return result + + +@dataclass(frozen=True, slots=True) +class Termination: + """Passive short, open, matched, or finite-impedance termination.""" + + kind: str + impedance_ohm: ComplexValue | None = None + + def __post_init__(self) -> None: + if not isinstance(self.kind, str): + raise TypeError("termination kind must be a string") + allowed = {"short", "open", "matched", "impedance"} + if self.kind not in allowed: + raise ValueError(f"termination kind must be one of {sorted(allowed)}") + if self.kind in {"short", "open"}: + if self.impedance_ohm is not None: + raise ValueError(f"{self.kind} termination must not include impedance_ohm") + return + if not isinstance(self.impedance_ohm, ComplexValue): + raise TypeError(f"{self.kind} termination requires ComplexValue impedance_ohm") + impedance = self.impedance_ohm.value + minimum_real = 0.0 + if impedance.real < minimum_real: + raise ValueError("termination impedance must be passive (real part >= 0)") + if self.kind == "matched" and impedance.real == 0.0: + raise ValueError("matched termination impedance must have positive real part") + + @classmethod + def short(cls) -> Termination: + return cls(kind="short") + + @classmethod + def open(cls) -> Termination: + return cls(kind="open") + + @classmethod + def matched(cls, impedance_ohm: Any = 50.0) -> Termination: + return cls(kind="matched", impedance_ohm=ComplexValue.from_complex(impedance_ohm)) + + @classmethod + def impedance(cls, impedance_ohm: Any) -> Termination: + return cls(kind="impedance", impedance_ohm=ComplexValue.from_complex(impedance_ohm)) + + @property + def resolved_impedance_ohm(self) -> complex | None: + if self.kind == "open": + return None + if self.kind == "short": + return 0.0j + assert self.impedance_ohm is not None + return self.impedance_ohm.value + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +def terminated_input_impedance( + network: ABCDMatrix, termination: Termination +) -> complex | None: + """Return input impedance, with ``None`` representing an ideal open circuit.""" + + if not isinstance(network, ABCDMatrix): + raise TypeError("network must be an ABCDMatrix") + if not isinstance(termination, Termination): + raise TypeError("termination must be a Termination") + + load = termination.resolved_impedance_ohm + if load is None: + numerator, denominator = network.A, network.C + else: + direct_numerator = network.A * load + network.B + direct_denominator = network.C * load + network.D + direct_is_finite = all( + math.isfinite(component) + for value in (direct_numerator, direct_denominator) + for component in (value.real, value.imag) + ) + if direct_is_finite: + numerator, denominator = direct_numerator, direct_denominator + else: + # Divide the fractional-linear transform by Z_load only when the + # direct form overflows. Keeping the direct form when possible + # also preserves exact identities at the largest finite float. + inverse_load = 1.0 / load + numerator = network.A + network.B * inverse_load + denominator = network.C + network.D * inverse_load + if denominator == 0.0j: + if numerator == 0.0j: + raise ValueError("network and termination produce an indeterminate input impedance") + return None + return _finite_complex("input impedance", numerator / denominator) + + +@dataclass(frozen=True, slots=True) +class ReflectionResult: + """Input reflection plus finite, deterministic serialization fields.""" + + reference_impedance_ohm: float + termination: Termination + input_impedance_ohm: ComplexValue | None + input_is_open: bool + reflection_coefficient: ComplexValue + magnitude: float + phase_deg: float | None + + def __post_init__(self) -> None: + object.__setattr__( + self, + "reference_impedance_ohm", + _positive_real("reference_impedance_ohm", self.reference_impedance_ohm), + ) + if not isinstance(self.termination, Termination): + raise TypeError("termination must be a Termination") + if self.input_impedance_ohm is not None and not isinstance( + self.input_impedance_ohm, ComplexValue + ): + raise TypeError("input_impedance_ohm must be a ComplexValue or None") + if not isinstance(self.input_is_open, bool): + raise TypeError("input_is_open must be a bool") + if self.input_is_open != (self.input_impedance_ohm is None): + raise ValueError("input_is_open is inconsistent with input_impedance_ohm") + if not isinstance(self.reflection_coefficient, ComplexValue): + raise TypeError("reflection_coefficient must be a ComplexValue") + magnitude = _nonnegative_real("magnitude", self.magnitude) + if not math.isclose(magnitude, abs(self.gamma), rel_tol=2.0e-15, abs_tol=0.0): + raise ValueError("magnitude is inconsistent with reflection_coefficient") + object.__setattr__(self, "magnitude", magnitude) + if self.gamma == 0.0j: + if self.phase_deg is not None: + raise ValueError("phase_deg must be None for zero reflection") + else: + if self.phase_deg is None: + raise ValueError("phase_deg is required for nonzero reflection") + phase = _finite_real("phase_deg", self.phase_deg) + if phase < -180.0 or phase > 180.0: + raise ValueError("phase_deg must be in [-180, 180]") + object.__setattr__(self, "phase_deg", phase) + + @property + def gamma(self) -> complex: + return self.reflection_coefficient.value + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + +def input_reflection( + network: ABCDMatrix, + termination: Termination, + reference_impedance_ohm: float = 50.0, +) -> ReflectionResult: + """Compute the voltage-wave input reflection for a positive real reference.""" + + reference = _positive_real("reference_impedance_ohm", reference_impedance_ohm) + input_impedance = terminated_input_impedance(network, termination) + if input_impedance is None: + reflection = 1.0 + 0.0j + encoded_input = None + else: + impedance_scale = max( + abs(input_impedance.real), abs(input_impedance.imag), reference + ) + scaled_impedance = input_impedance / impedance_scale + scaled_reference = reference / impedance_scale + denominator = scaled_impedance + scaled_reference + if denominator == 0.0j: + raise ValueError("input impedance makes reflection coefficient singular") + reflection = _finite_complex( + "reflection coefficient", + (scaled_impedance - scaled_reference) / denominator, + ) + encoded_input = ComplexValue.from_complex(input_impedance) + magnitude = _nonnegative_real("reflection magnitude", abs(reflection)) + phase = None if reflection == 0.0j else math.degrees(cmath.phase(reflection)) + return ReflectionResult( + reference_impedance_ohm=reference, + termination=termination, + input_impedance_ohm=encoded_input, + input_is_open=input_impedance is None, + reflection_coefficient=ComplexValue.from_complex(reflection), + magnitude=magnitude, + phase_deg=phase, + ) + + +def circular_phase_error_deg(actual: Any, expected: Any) -> float: + """Return ``phase(actual)-phase(expected)`` wrapped to [-180, 180] degrees.""" + + actual_value = _finite_complex("actual", actual) + expected_value = _finite_complex("expected", expected) + if actual_value == 0.0j or expected_value == 0.0j: + raise ValueError("phase error is undefined for a zero-magnitude value") + difference = math.degrees(cmath.phase(actual_value) - cmath.phase(expected_value)) + wrapped = math.remainder(difference, 360.0) + if wrapped == -180.0: + wrapped = 180.0 + return 0.0 if wrapped == 0.0 else wrapped + + +@dataclass(frozen=True, slots=True) +class SParameterErrorMetrics: + """Aggregate complex-S errors with phase differences computed circularly.""" + + sample_count: int + phase_sample_count: int + complex_mae: float + complex_rmse: float + normalized_complex_rmse: float | None + max_abs_complex_error: float + magnitude_rmse: float + phase_mae_deg: float | None + phase_rmse_deg: float | None + max_abs_phase_error_deg: float | None + + def __post_init__(self) -> None: + if isinstance(self.sample_count, bool) or not isinstance(self.sample_count, Integral): + raise TypeError("sample_count must be an integer") + if isinstance(self.phase_sample_count, bool) or not isinstance( + self.phase_sample_count, Integral + ): + raise TypeError("phase_sample_count must be an integer") + sample_count = int(self.sample_count) + phase_sample_count = int(self.phase_sample_count) + if sample_count <= 0: + raise ValueError("sample_count must be positive") + if not 0 <= phase_sample_count <= sample_count: + raise ValueError("phase_sample_count must be between zero and sample_count") + object.__setattr__(self, "sample_count", sample_count) + object.__setattr__(self, "phase_sample_count", phase_sample_count) + + required_metrics = ( + "complex_mae", + "complex_rmse", + "max_abs_complex_error", + "magnitude_rmse", + ) + for name in required_metrics: + object.__setattr__(self, name, _nonnegative_real(name, getattr(self, name))) + if self.normalized_complex_rmse is not None: + object.__setattr__( + self, + "normalized_complex_rmse", + _nonnegative_real( + "normalized_complex_rmse", self.normalized_complex_rmse + ), + ) + + phase_names = ("phase_mae_deg", "phase_rmse_deg", "max_abs_phase_error_deg") + if phase_sample_count == 0: + if any(getattr(self, name) is not None for name in phase_names): + raise ValueError("phase metrics must be None when phase_sample_count is zero") + else: + for name in phase_names: + value = getattr(self, name) + if value is None: + raise ValueError( + "phase metrics are required when phase_sample_count is positive" + ) + normalized = _nonnegative_real(name, value) + if normalized > 180.0: + raise ValueError(f"{name} must not exceed 180 degrees") + object.__setattr__(self, name, normalized) + + def to_dict(self) -> dict[str, int | float | None]: + return asdict(self) + + +def _finite_complex_array(name: str, values: Sequence[complex] | np.ndarray) -> np.ndarray: + try: + array = np.asarray(values, dtype=np.complex128) + except OverflowError as exc: + raise ValueError(f"{name} must contain only finite complex128 values") from exc + except (TypeError, ValueError) as exc: + raise TypeError(f"{name} must be an array of complex-compatible values") from exc + if array.size == 0: + raise ValueError(f"{name} must not be empty") + if not np.all(np.isfinite(array.real) & np.isfinite(array.imag)): + raise ValueError(f"{name} must contain only finite values") + return array + + +def _scaled_mean(nonnegative_values: np.ndarray) -> float: + """Return a finite mean without overflowing the intermediate sum.""" + + scale = float(np.max(nonnegative_values)) + if scale == 0.0: + return 0.0 + return float(scale * np.mean(nonnegative_values / scale)) + + +def _scaled_root_mean_square(nonnegative_values: np.ndarray) -> float: + """Return a finite RMS without squaring values at their original scale.""" + + scale = float(np.max(nonnegative_values)) + if scale == 0.0: + return 0.0 + scaled = nonnegative_values / scale + return float(scale * np.sqrt(np.mean(scaled * scaled))) + + +def _circular_phase_differences_deg( + actual: np.ndarray, expected: np.ndarray +) -> np.ndarray: + """Return wrapped phase differences without magnitude products.""" + + differences = np.degrees(np.angle(actual) - np.angle(expected)) + wrapped = np.remainder(differences + 180.0, 360.0) - 180.0 + wrapped = np.where(wrapped == -180.0, 180.0, wrapped) + return np.where(wrapped == 0.0, 0.0, wrapped) + + +def complex_s_error_metrics( + actual: Sequence[complex] | np.ndarray, + expected: Sequence[complex] | np.ndarray, + *, + phase_magnitude_floor: float = 0.0, +) -> SParameterErrorMetrics: + """Compare equal-shaped complex S arrays without phase-wrap artifacts. + + Samples for which either magnitude is at or below + ``phase_magnitude_floor`` are excluded only from the phase statistics; they + remain in all complex and magnitude-error statistics. + """ + + actual_array = _finite_complex_array("actual", actual) + expected_array = _finite_complex_array("expected", expected) + if actual_array.shape != expected_array.shape: + raise ValueError( + f"actual and expected shapes differ: {actual_array.shape} != {expected_array.shape}" + ) + floor = _nonnegative_real("phase_magnitude_floor", phase_magnitude_floor) + actual_flat = actual_array.reshape(-1) + expected_flat = expected_array.reshape(-1) + with np.errstate(over="ignore", invalid="ignore"): + errors = actual_flat - expected_flat + absolute_errors = np.abs(errors) + actual_magnitudes = np.abs(actual_flat) + expected_magnitudes = np.abs(expected_flat) + derived_arrays = (absolute_errors, actual_magnitudes, expected_magnitudes) + if any(not np.all(np.isfinite(values)) for values in derived_arrays): + raise ValueError("actual and expected produce errors outside the finite float range") + + complex_mae = _scaled_mean(absolute_errors) + complex_rmse = _scaled_root_mean_square(absolute_errors) + expected_rms = _scaled_root_mean_square(expected_magnitudes) + if expected_rms > 0.0: + normalized_rmse = complex_rmse / expected_rms + if not math.isfinite(normalized_rmse): + raise ValueError("normalized complex RMSE exceeds the finite float range") + else: + normalized_rmse = None + magnitude_rmse = _scaled_root_mean_square( + np.abs(actual_magnitudes - expected_magnitudes) + ) + + phase_mask = (actual_magnitudes > floor) & (expected_magnitudes > floor) + phase_errors = _circular_phase_differences_deg( + actual_flat[phase_mask], expected_flat[phase_mask] + ) + if phase_errors.size: + phase_mae = float(np.mean(np.abs(phase_errors))) + phase_rmse = float(np.sqrt(np.mean(phase_errors**2))) + max_phase = float(np.max(np.abs(phase_errors))) + else: + phase_mae = None + phase_rmse = None + max_phase = None + + return SParameterErrorMetrics( + sample_count=int(actual_flat.size), + phase_sample_count=int(phase_errors.size), + complex_mae=complex_mae, + complex_rmse=complex_rmse, + normalized_complex_rmse=normalized_rmse, + max_abs_complex_error=float(np.max(absolute_errors)), + magnitude_rmse=magnitude_rmse, + phase_mae_deg=phase_mae, + phase_rmse_deg=phase_rmse, + max_abs_phase_error_deg=max_phase, + ) diff --git a/src/scatter3d/fem/config.py b/src/scatter3d/fem/config.py index d181b73..346e612 100644 --- a/src/scatter3d/fem/config.py +++ b/src/scatter3d/fem/config.py @@ -2,15 +2,34 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass, field from math import isfinite, pi from types import MappingProxyType -from typing import Any, Mapping +from typing import Any EPSILON_0 = 8.854_187_812_8e-12 MU_0 = 1.256_637_062_12e-6 SPEED_OF_LIGHT = 1.0 / (EPSILON_0 * MU_0) ** 0.5 +_RESERVED_TOP_LEVEL_PETSC_OPTIONS = frozenset( + { + "ksp_atol", + "ksp_error_if_not_converged", + "ksp_max_it", + "ksp_pc_side", + "ksp_rtol", + "ksp_type", + "pc_factor_mat_solver_type", + "pc_mg_galerkin", + "pc_mg_levels", + "pc_mg_type", + "pc_side", + "pc_type", + "pc_use_amat", + } +) + def _finite_complex(value: complex, name: str) -> complex: result = complex(value) @@ -146,7 +165,7 @@ def __post_init__(self) -> None: raise ValueError("PML bounds and thickness must contain three values") if not all(isfinite(v) for v in (*lo, *hi, *thickness)): raise ValueError("PML bounds and thickness must be finite") - if any(a >= b for a, b in zip(lo, hi)): + if any(a >= b for a, b in zip(lo, hi, strict=False)): raise ValueError("each physical_min_m coordinate must be below physical_max_m") if any(v < 0 for v in thickness) or not any(v > 0 for v in thickness): raise ValueError("PML thickness must be nonnegative and nonzero on at least one axis") @@ -218,9 +237,12 @@ class LinearSolverConfig: pc_type: str = "lu" factor_solver_type: str | None = "mumps" solver_path: str = "direct" + preconditioning_side: str = "left" relative_tolerance: float = 1.0e-10 absolute_tolerance: float = 1.0e-12 maximum_iterations: int = 2_000 + preconditioner_absorption_shift: float = 0.0 + p_multigrid_coarse_degree: int | None = None error_if_not_converged: bool = True petsc_options: Mapping[str, str | int | float | None] = field(default_factory=dict) @@ -235,18 +257,65 @@ def __post_init__(self) -> None: raise ValueError("direct solver_path requires an LU or Cholesky preconditioner") if path == "iterative" and self.pc_type.lower() in factor_types: raise ValueError("iterative solver_path forbids LU and Cholesky factorization") + side = self.preconditioning_side.lower() + if side not in ("left", "right", "symmetric"): + raise ValueError( + "preconditioning_side must be 'left', 'right', or 'symmetric'" + ) rtol = float(self.relative_tolerance) atol = float(self.absolute_tolerance) maximum = int(self.maximum_iterations) + absorption_shift = float(self.preconditioner_absorption_shift) + coarse_degree = ( + None + if self.p_multigrid_coarse_degree is None + else int(self.p_multigrid_coarse_degree) + ) if not (isfinite(rtol) and isfinite(atol) and rtol > 0 and atol >= 0): raise ValueError("solver tolerances must be finite with rtol > 0 and atol >= 0") if maximum < 1: raise ValueError("maximum_iterations must be positive") + if not isfinite(absorption_shift) or absorption_shift < 0: + raise ValueError( + "preconditioner_absorption_shift must be finite and nonnegative" + ) + if path == "direct" and absorption_shift != 0.0: + raise ValueError( + "preconditioner_absorption_shift is available only for iterative solvers" + ) + if coarse_degree is not None: + if coarse_degree not in (1, 2): + raise ValueError("p_multigrid_coarse_degree must be 1 or 2") + if path != "iterative" or self.pc_type.lower() != "mg": + raise ValueError( + "p_multigrid_coarse_degree requires iterative solver_path and pc_type='mg'" + ) + elif self.pc_type.lower() == "mg": + raise ValueError("pc_type='mg' requires p_multigrid_coarse_degree") object.__setattr__(self, "relative_tolerance", rtol) object.__setattr__(self, "absolute_tolerance", atol) object.__setattr__(self, "maximum_iterations", maximum) + object.__setattr__( + self, "preconditioner_absorption_shift", absorption_shift + ) + object.__setattr__(self, "p_multigrid_coarse_degree", coarse_degree) + normalized_options: dict[str, str | int | float | None] = {} + for raw_key, value in self.petsc_options.items(): + key = str(raw_key).strip().lstrip("-").strip().lower() + if not key: + raise ValueError("PETSc option keys must not be empty") + if key in _RESERVED_TOP_LEVEL_PETSC_OPTIONS: + raise ValueError( + f"petsc_options may not override typed top-level option {key!r}" + ) + if key in normalized_options: + raise ValueError(f"duplicate normalized PETSc option key {key!r}") + normalized_options[key] = value object.__setattr__(self, "solver_path", path) - object.__setattr__(self, "petsc_options", MappingProxyType(dict(self.petsc_options))) + object.__setattr__(self, "preconditioning_side", side) + object.__setattr__( + self, "petsc_options", MappingProxyType(normalized_options) + ) @property def is_direct(self) -> bool: @@ -256,8 +325,12 @@ def is_direct(self) -> bool: def is_iterative(self) -> bool: return self.solver_path == "iterative" + @property + def uses_p_multigrid(self) -> bool: + return self.p_multigrid_coarse_degree is not None + @classmethod - def direct(cls, *, factor_solver_type: str = "mumps") -> "LinearSolverConfig": + def direct(cls, *, factor_solver_type: str = "mumps") -> LinearSolverConfig: """Small-problem reference path: one sparse factorization per frequency.""" return cls( @@ -268,7 +341,7 @@ def direct(cls, *, factor_solver_type: str = "mumps") -> "LinearSolverConfig": ) @classmethod - def iterative_maxwell(cls, **overrides: Any) -> "LinearSolverConfig": + def iterative_maxwell(cls, **overrides: Any) -> LinearSolverConfig: """Distributed, non-factorizing baseline for large edge-element systems. FGMRES with overlapping additive Schwarz and local ILU(0) is a @@ -283,6 +356,7 @@ def iterative_maxwell(cls, **overrides: Any) -> "LinearSolverConfig": "ksp_type": "fgmres", "pc_type": "asm", "factor_solver_type": None, + "preconditioning_side": "right", "relative_tolerance": 1.0e-8, "maximum_iterations": 1_000, "petsc_options": { @@ -296,15 +370,58 @@ def iterative_maxwell(cls, **overrides: Any) -> "LinearSolverConfig": values.update(overrides) return cls(**values) + @classmethod + def iterative_p_multigrid( + cls, + *, + coarse_degree: int = 1, + **overrides: Any, + ) -> LinearSolverConfig: + """Two-level assembled p-multigrid with an exact low-order coarse solve.""" + + values: dict[str, Any] = { + "solver_path": "iterative", + "ksp_type": "fgmres", + "pc_type": "mg", + "factor_solver_type": None, + "preconditioning_side": "right", + "relative_tolerance": 1.0e-8, + "maximum_iterations": 1_000, + "p_multigrid_coarse_degree": coarse_degree, + "petsc_options": { + "ksp_gmres_restart": 80, + "mg_levels_1_ksp_type": "richardson", + "mg_levels_1_ksp_max_it": 1, + "mg_levels_1_pc_type": "asm", + "mg_levels_1_pc_asm_overlap": 1, + "mg_levels_1_sub_ksp_type": "preonly", + "mg_levels_1_sub_pc_type": "lu", + "mg_levels_1_sub_pc_factor_mat_solver_type": "mumps", + "mg_coarse_ksp_type": "preonly", + "mg_coarse_pc_type": "lu", + "mg_coarse_pc_factor_mat_solver_type": "mumps", + }, + } + option_overrides = overrides.pop("petsc_options", None) + values.update(overrides) + if option_overrides is not None: + merged_options = dict(values["petsc_options"]) + merged_options.update(option_overrides) + values["petsc_options"] = merged_options + return cls(**values) + def canonical(self) -> dict[str, Any]: return { "ksp_type": self.ksp_type, "pc_type": self.pc_type, "factor_solver_type": self.factor_solver_type, "solver_path": self.solver_path, + "preconditioning_side": self.preconditioning_side, "relative_tolerance": self.relative_tolerance, "absolute_tolerance": self.absolute_tolerance, "maximum_iterations": self.maximum_iterations, + "preconditioner_absorption_shift": self.preconditioner_absorption_shift, + "p_multigrid_coarse_degree": self.p_multigrid_coarse_degree, "error_if_not_converged": self.error_if_not_converged, "petsc_options": dict(sorted(self.petsc_options.items())), } diff --git a/src/scatter3d/fem/diagnostics.py b/src/scatter3d/fem/diagnostics.py index 0209802..b5c88a8 100644 --- a/src/scatter3d/fem/diagnostics.py +++ b/src/scatter3d/fem/diagnostics.py @@ -2,11 +2,17 @@ from __future__ import annotations -from dataclasses import dataclass +import os +import re import sys +import tempfile +from contextlib import suppress +from dataclasses import dataclass +from math import isclose +from pathlib import Path from typing import Any -from .config import ExperimentMaterials, Material, MaterialMap +from .config import ExperimentMaterials, LinearSolverConfig, Material, MaterialMap @dataclass(frozen=True, slots=True) @@ -16,6 +22,527 @@ class MaterialChange: dut: Material +@dataclass(frozen=True, slots=True) +class SolverComponentDiagnostics: + """Effective PETSc types for one KSP/PC component after setup.""" + + path: str + ksp_type: str + pc_type: str + factor_solver_type: str | None + options_prefix: str + maximum_iterations: int + norm_type: str + mpi_ranks: tuple[int, ...] + instances: int + + +@dataclass(frozen=True, slots=True) +class RequestedSolverHierarchy: + """Requested top-level hierarchy, including the exact PETSc options.""" + + ksp_type: str + preconditioning_side: str + pc_type: str + factor_solver_type: str | None + options_prefix: str + petsc_options: tuple[tuple[str, str | int | float | None], ...] + + +@dataclass(frozen=True, slots=True) +class EffectiveSolverHierarchy: + """Observed PETSc hierarchy after nested objects have been created.""" + + preconditioning_side: str + top_level: SolverComponentDiagnostics + pc_uses_amat: bool + relative_tolerance: float + absolute_tolerance: float + maximum_iterations: int + asm_type: str | None + asm_overlap: int | None + asm_subdomain_solvers: tuple[SolverComponentDiagnostics, ...] + mg_levels: int | None + mg_type: str | None + mg_cycle_type: str | None + mg_galerkin: str | None + mg_fine_smoother: SolverComponentDiagnostics | None + mg_fine_asm_type: str | None + mg_fine_asm_overlap: int | None + mg_fine_asm_subdomain_solvers: tuple[SolverComponentDiagnostics, ...] + mg_coarse_solver: SolverComponentDiagnostics | None + petsc_view_ascii: str + + +@dataclass(frozen=True, slots=True) +class SolverHierarchyDiagnostics: + """Side-by-side requested and setup-observed PETSc configuration.""" + + requested: RequestedSolverHierarchy + effective: EffectiveSolverHierarchy + + +def _factor_solver_type(pc: Any) -> str | None: + if str(pc.getType()).lower() not in {"lu", "cholesky"}: + return None + getter = getattr(pc, "getFactorSolverType", None) + if getter is None: + return None + value = getter() + return None if value is None else str(value) + + +def _local_solver_component(ksp: Any, path: str) -> SolverComponentDiagnostics: + from petsc4py import PETSc + + pc = ksp.getPC() + norm_value = ksp.getNormType() + norm_type = { + PETSc.KSP.NormType.NONE: "none", + PETSc.KSP.NormType.PRECONDITIONED: "preconditioned", + PETSc.KSP.NormType.UNPRECONDITIONED: "unpreconditioned", + PETSc.KSP.NormType.NATURAL: "natural", + }.get(norm_value, str(norm_value)) + return SolverComponentDiagnostics( + path=path, + ksp_type=str(ksp.getType()), + pc_type=str(pc.getType()), + factor_solver_type=_factor_solver_type(pc), + options_prefix=str(ksp.getOptionsPrefix() or ""), + maximum_iterations=int(ksp.getTolerances()[3]), + norm_type=norm_type, + mpi_ranks=(), + instances=1, + ) + + +def _aggregate_solver_components( + comm: Any, + local_components: tuple[SolverComponentDiagnostics, ...], + path: str, +) -> tuple[SolverComponentDiagnostics, ...]: + """Allgather and deduplicate live solver components across MPI ranks.""" + + local_payload = tuple( + ( + item.ksp_type, + item.pc_type, + item.factor_solver_type, + item.options_prefix, + item.maximum_iterations, + item.norm_type, + ) + for item in local_components + ) + gathered = comm.allgather(local_payload) + groups: dict[tuple[str, str, str | None, str, int, str], dict[str, Any]] = {} + for rank, components in enumerate(gathered): + for component in components: + group = groups.setdefault(component, {"ranks": set(), "instances": 0}) + group["ranks"].add(rank) + group["instances"] += 1 + return tuple( + SolverComponentDiagnostics( + path=path if len(groups) == 1 else f"{path}.variant[{index}]", + ksp_type=key[0], + pc_type=key[1], + factor_solver_type=key[2], + options_prefix=key[3], + maximum_iterations=key[4], + norm_type=key[5], + mpi_ranks=tuple(sorted(group["ranks"])), + instances=int(group["instances"]), + ) + for index, (key, group) in enumerate( + sorted(groups.items(), key=lambda item: repr(item[0])) + ) + ) + + +def _consistent_rank_value(comm: Any, value: Any, name: str) -> Any: + values = comm.allgather(value) + if any(item != values[0] for item in values[1:]): + raise RuntimeError(f"effective PETSc {name} differs across MPI ranks: {values}") + return values[0] + + +_ASM_OVERLAP_PATTERN = re.compile( + r"\bamount of overlap\s*=\s*(\d+)\b", re.IGNORECASE +) +_ASM_TYPE_PATTERN = re.compile( + r"\brestriction/interpolation type\s*-\s*([A-Za-z_]+)\b", + re.IGNORECASE, +) +_MG_HEADER_PATTERN = re.compile( + r"\btype is\s+([A-Za-z_]+),\s*levels=(\d+)\s+cycles=([A-Za-z_]+)\b", + re.IGNORECASE, +) + + +def parse_petsc_asm_view(view_text: str) -> tuple[str, int]: + """Parse PETSc 3.24's official ASCII ``PCView_ASM`` fields.""" + + type_match = _ASM_TYPE_PATTERN.search(view_text) + overlap_match = _ASM_OVERLAP_PATTERN.search(view_text) + missing = [] + if type_match is None: + missing.append("restriction/interpolation type") + if overlap_match is None: + missing.append("amount of overlap") + if missing: + raise ValueError( + "PETSc ASM view is missing required field(s): " + ", ".join(missing) + ) + assert type_match is not None and overlap_match is not None + return type_match.group(1).lower(), int(overlap_match.group(1)) + + +def parse_petsc_mg_view(view_text: str) -> tuple[str, int, str, str]: + """Parse PETSc 3.24's effective PCMG type, level, cycle, and Galerkin mode.""" + + header = _MG_HEADER_PATTERN.search(view_text) + if header is None: + raise ValueError("PETSc MG view is missing type/levels/cycles") + if "Not using Galerkin computed coarse grid matrices" in view_text: + galerkin = "none" + elif "Using externally compute Galerkin coarse grid matrices" in view_text: + galerkin = "external" + elif "Using Galerkin computed coarse grid matrices for pmat" in view_text: + galerkin = "pmat" + elif "Using Galerkin computed coarse grid matrices for mat" in view_text: + galerkin = "mat" + elif "Using Galerkin computed coarse grid matrices" in view_text: + galerkin = "both" + else: + raise ValueError("PETSc MG view is missing Galerkin mode") + return ( + header.group(1).lower(), + int(header.group(2)), + header.group(3).lower(), + galerkin, + ) + + +def _remove_temporary_view(path: str) -> None: + """Best-effort cleanup that must not strand peers at a later barrier.""" + + with suppress(OSError): + Path(path).unlink() + + +def _collective_temporary_view_path(comm: Any) -> str: + """Create a rank-0 temporary path and broadcast success or failure.""" + + envelope: tuple[str | None, str | None] | None = None + if comm.rank == 0: + descriptor = None + path = None + try: + descriptor, path = tempfile.mkstemp( + prefix="scatter3d-ksp-view-", suffix=".txt" + ) + os.close(descriptor) + descriptor = None + envelope = (path, None) + except OSError as exc: + if descriptor is not None: + with suppress(OSError): + os.close(descriptor) + if path is not None: + _remove_temporary_view(path) + envelope = (None, f"{type(exc).__name__}: {exc}") + path, error = comm.bcast(envelope, root=0) + if error is not None: + raise RuntimeError(f"failed to create PETSc ASCII KSP view: {error}") + if path is None: + raise RuntimeError("failed to create PETSc ASCII KSP view: missing path") + return path + + +def _capture_petsc_ascii_view(ksp: Any, comm: Any) -> str: + """Collectively capture ``KSPView`` through PETSc's supported ASCII viewer.""" + + from petsc4py import PETSc + + path = _collective_temporary_view_path(comm) + viewer = None + try: + try: + viewer = PETSc.Viewer().createASCII( + path, mode=PETSc.Viewer.FileMode.WRITE, comm=comm + ) + ksp.view(viewer) + finally: + if viewer is not None: + viewer.destroy() + comm.barrier() + payload: tuple[str | None, str | None] | None = None + if comm.rank == 0: + try: + payload = (Path(path).read_text(encoding="utf-8"), None) + except OSError as exc: + payload = (None, f"{type(exc).__name__}: {exc}") + text, error = comm.bcast(payload, root=0) + if error is not None: + raise RuntimeError(f"failed to read PETSc ASCII KSP view: {error}") + assert text is not None + return text + finally: + comm.barrier() + if comm.rank == 0: + _remove_temporary_view(path) + comm.barrier() + + +def validate_effective_solver_hierarchy( + hierarchy: SolverHierarchyDiagnostics, config: LinearSolverConfig +) -> None: + """Fail closed if PETSc changed any typed top-level solver setting.""" + + effective = hierarchy.effective + top = effective.top_level + expected_ksp = config.ksp_type.lower() + expected_pc = config.pc_type.lower() + actual_ksp = top.ksp_type.lower() + actual_pc = top.pc_type.lower() + if actual_ksp != expected_ksp: + raise RuntimeError( + f"effective top-level KSP {actual_ksp!r} != requested {expected_ksp!r}" + ) + if actual_pc != expected_pc: + raise RuntimeError( + f"effective top-level PC {actual_pc!r} != requested {expected_pc!r}" + ) + factor_types = {"lu", "cholesky"} + if config.is_iterative and actual_pc in factor_types: + raise RuntimeError("iterative solver resolved to a global factorizing PC") + if config.is_direct and actual_pc not in factor_types: + raise RuntimeError("direct solver did not resolve to a factorizing PC") + if ( + config.factor_solver_type is not None + and (top.factor_solver_type or "").lower() + != config.factor_solver_type.lower() + ): + raise RuntimeError( + "effective factor solver " + f"{top.factor_solver_type!r} != requested {config.factor_solver_type!r}" + ) + if effective.preconditioning_side != config.preconditioning_side: + raise RuntimeError( + "effective preconditioning side " + f"{effective.preconditioning_side!r} != requested " + f"{config.preconditioning_side!r}" + ) + if not isclose( + effective.relative_tolerance, + config.relative_tolerance, + rel_tol=1.0e-15, + abs_tol=0.0, + ) or not isclose( + effective.absolute_tolerance, + config.absolute_tolerance, + rel_tol=1.0e-15, + abs_tol=0.0, + ): + raise RuntimeError("effective PETSc tolerances differ from typed configuration") + if effective.maximum_iterations != config.maximum_iterations: + raise RuntimeError( + "effective PETSc maximum iterations differs from typed configuration" + ) + if config.uses_p_multigrid: + if ( + effective.mg_levels != 2 + or effective.mg_type != "multiplicative" + or effective.mg_galerkin != "none" + or effective.pc_uses_amat + ): + raise RuntimeError( + "effective p-multigrid must be two-level, multiplicative, " + "non-Galerkin, and use Pmat" + ) + fine = effective.mg_fine_smoother + coarse = effective.mg_coarse_solver + if ( + fine is None + or fine.ksp_type.lower() != "richardson" + or fine.pc_type.lower() != "asm" + or fine.maximum_iterations != 1 + or fine.norm_type != "none" + ): + raise RuntimeError( + "effective p-multigrid fine smoother is not one-step Richardson/ASM" + ) + if ( + coarse is None + or coarse.ksp_type.lower() != "preonly" + or coarse.pc_type.lower() != "lu" + or (coarse.factor_solver_type or "").lower() != "mumps" + ): + raise RuntimeError( + "effective p-multigrid coarse solver is not preonly/LU/MUMPS" + ) + if not effective.mg_fine_asm_subdomain_solvers: + raise RuntimeError("effective p-multigrid fine ASM has no subdomain solvers") + expected_asm_type = str( + config.petsc_options.get("mg_levels_1_pc_asm_type", "restrict") + ).lower() + expected_overlap = int( + config.petsc_options.get("mg_levels_1_pc_asm_overlap", 1) + ) + if ( + effective.mg_fine_asm_type != expected_asm_type + or effective.mg_fine_asm_overlap != expected_overlap + ): + raise RuntimeError( + "effective p-multigrid fine ASM type or overlap differs from request" + ) + if any( + item.ksp_type.lower() != "preonly" + or item.pc_type.lower() != "lu" + or (item.factor_solver_type or "").lower() != "mumps" + for item in effective.mg_fine_asm_subdomain_solvers + ): + raise RuntimeError( + "effective p-multigrid fine ASM local solver is not preonly/LU/MUMPS" + ) + + +def inspect_petsc_solver_hierarchy( + ksp: Any, + config: LinearSolverConfig, + option_prefix: str, + comm: Any, +) -> SolverHierarchyDiagnostics: + """Inspect actual top-level and ASM-nested solver types after ``KSPSetUp``. + + The requested options are retained separately because PETSc does not expose + getters for every ASM option (notably overlap) through petsc4py. Nested KSP + and PC types, including local factor backends, are queried from the live + objects and are therefore not inferred from the request. + """ + + from petsc4py import PETSc + + side_value = ksp.getPCSide() + side = { + PETSc.PC.Side.LEFT: "left", + PETSc.PC.Side.RIGHT: "right", + PETSc.PC.Side.SYMMETRIC: "symmetric", + }.get(side_value, str(side_value)) + side = _consistent_rank_value(comm, side, "preconditioning side") + tolerances = _consistent_rank_value( + comm, + tuple(ksp.getTolerances()), + "top-level tolerances", + ) + pc = ksp.getPC() + pc_uses_amat = _consistent_rank_value( + comm, bool(pc.getUseAmat()), "top-level PC use-Amat flag" + ) + top_levels = _aggregate_solver_components( + comm, (_local_solver_component(ksp, "top"),), "top" + ) + if len(top_levels) != 1: + raise RuntimeError( + "effective top-level PETSc hierarchy differs across MPI ranks" + ) + asm_type = None + asm_overlap = None + view_text = _capture_petsc_ascii_view(ksp, comm) + subdomains: tuple[SolverComponentDiagnostics, ...] = () + mg_levels = None + mg_type = None + mg_cycle_type = None + mg_galerkin = None + mg_fine_smoother = None + mg_fine_asm_type = None + mg_fine_asm_overlap = None + mg_fine_subdomains: tuple[SolverComponentDiagnostics, ...] = () + mg_coarse_solver = None + if str(pc.getType()).lower() == "asm": + asm_type, asm_overlap = parse_petsc_asm_view(view_text) + local_subdomains = tuple( + _local_solver_component(sub_ksp, f"asm.subdomain[{index}]") + for index, sub_ksp in enumerate(pc.getASMSubKSP()) + ) + subdomains = _aggregate_solver_components( + comm, local_subdomains, "asm.subdomains" + ) + elif str(pc.getType()).lower() == "mg": + mg_type, mg_levels, mg_cycle_type, mg_galerkin = parse_petsc_mg_view( + view_text + ) + live_levels = _consistent_rank_value( + comm, int(pc.getMGLevels()), "MG level count" + ) + if live_levels != mg_levels: + raise RuntimeError( + f"PETSc MG view levels={mg_levels} != live levels={live_levels}" + ) + fine_ksp = pc.getMGSmoother(mg_levels - 1) + coarse_ksp = pc.getMGCoarseSolve() + fine_groups = _aggregate_solver_components( + comm, + (_local_solver_component(fine_ksp, "mg.fine"),), + "mg.fine", + ) + coarse_groups = _aggregate_solver_components( + comm, + (_local_solver_component(coarse_ksp, "mg.coarse"),), + "mg.coarse", + ) + if len(fine_groups) != 1 or len(coarse_groups) != 1: + raise RuntimeError("effective MG level solvers differ across MPI ranks") + mg_fine_smoother = fine_groups[0] + mg_coarse_solver = coarse_groups[0] + fine_pc = fine_ksp.getPC() + if str(fine_pc.getType()).lower() == "asm": + mg_fine_asm_type, mg_fine_asm_overlap = parse_petsc_asm_view( + view_text + ) + local_subdomains = tuple( + _local_solver_component(sub_ksp, f"mg.fine.asm[{index}]") + for index, sub_ksp in enumerate(fine_pc.getASMSubKSP()) + ) + mg_fine_subdomains = _aggregate_solver_components( + comm, local_subdomains, "mg.fine.asm.subdomains" + ) + return SolverHierarchyDiagnostics( + requested=RequestedSolverHierarchy( + ksp_type=config.ksp_type, + preconditioning_side=config.preconditioning_side, + pc_type=config.pc_type, + factor_solver_type=config.factor_solver_type, + options_prefix=option_prefix, + petsc_options=tuple( + (str(key), value) + for key, value in sorted(config.petsc_options.items()) + ), + ), + effective=EffectiveSolverHierarchy( + preconditioning_side=side, + top_level=top_levels[0], + pc_uses_amat=pc_uses_amat, + relative_tolerance=float(tolerances[0]), + absolute_tolerance=float(tolerances[1]), + maximum_iterations=int(tolerances[3]), + asm_type=asm_type, + asm_overlap=asm_overlap, + asm_subdomain_solvers=subdomains, + mg_levels=mg_levels, + mg_type=mg_type, + mg_cycle_type=mg_cycle_type, + mg_galerkin=mg_galerkin, + mg_fine_smoother=mg_fine_smoother, + mg_fine_asm_type=mg_fine_asm_type, + mg_fine_asm_overlap=mg_fine_asm_overlap, + mg_fine_asm_subdomain_solvers=mg_fine_subdomains, + mg_coarse_solver=mg_coarse_solver, + petsc_view_ascii=view_text, + ), + ) + + def compare_material_models(materials: ExperimentMaterials) -> tuple[MaterialChange, ...]: """Report reference/DUT changes without conflating their two model states.""" diff --git a/src/scatter3d/fem/forms.py b/src/scatter3d/fem/forms.py index f5824c6..c93d1ed 100644 --- a/src/scatter3d/fem/forms.py +++ b/src/scatter3d/fem/forms.py @@ -2,15 +2,25 @@ from __future__ import annotations +from collections.abc import Mapping, Sequence from dataclasses import dataclass from math import pi +from types import MappingProxyType from typing import Any import numpy as np -from .config import MaterialMap, MaxwellProblemConfig, PMLConfig, SPEED_OF_LIGHT +from .config import SPEED_OF_LIGHT, MaterialMap, MaxwellProblemConfig, PMLConfig from .pml import cartesian_pml_inverse_tensor, cartesian_pml_tensor -from .ports import PortExcitation +from .ports import ( + MatchedTEMPortExcitation, + PortDefinition, + UncalibratedSurfaceCurrentExcitation, + matched_tem_incident_coefficient, + matched_tem_operator_coefficient, + tangential_trace, + validate_port_definitions, +) from .tags import MeshTagContract @@ -27,9 +37,12 @@ class MaxwellForms: k0: Any epsilon_r: Any inverse_mu_r: Any + preconditioner_absorption_shift: Any bilinear_form: Any + preconditioner_bilinear_form: Any boundary_conditions: list[Any] ds: Any + matched_ports: Mapping[str, PortDefinition] _materials: MaterialMap _frequency_hz: float @@ -72,23 +85,104 @@ def update_frequency(self, frequency_hz: float) -> None: self.inverse_mu_r.x.scatter_forward() self._frequency_hz = frequency - def rhs_form(self, excitation: PortExcitation) -> Any: + def set_preconditioner_absorption_shift(self, value: float) -> None: + """Set the dimensionless artificial loss used only by the P operator. + + With the declared ``exp(-i*omega*t)`` convention, a positive value + adds ``+i*value`` to relative permittivity in the preconditioning + form. The physical form, material coefficients, and right-hand sides + are not mutated. + """ + + from math import isfinite + + from petsc4py import PETSc + + shift = float(value) + if not isfinite(shift) or shift < 0: + raise ValueError( + "preconditioner_absorption_shift must be finite and nonnegative" + ) + self.preconditioner_absorption_shift.value = PETSc.ScalarType(shift) + + def matched_port_rhs_form(self, excitation: MatchedTEMPortExcitation) -> Any: + """Compile the inward incident-mode RHS for one configured TEM port. + + For ``exp(-i omega t)``, integration by parts places + ``-2 i k0 Z_vac Y_f `` on the right-hand side. UFL + ``inner`` conjugates the test field (its second operand). + """ + import ufl from dolfinx import fem - from petsc4py import PETSc - expected = self.tag_contract.boundaries.ports.get(excitation.definition.name) - if expected != excitation.definition.facet_tag: + configured = self.matched_ports.get(excitation.definition.name) + if configured is None: + raise ValueError( + f"port {excitation.definition.name!r} is not configured in this operator" + ) + if configured != excitation.definition: + raise ValueError( + f"port {excitation.definition.name!r} excitation definition does not " + "match the operator definition" + ) + if excitation.mode.field.function_space is not self.function_space: raise ValueError( - f"port {excitation.definition.name!r} tag does not match mesh contract" + f"port {excitation.definition.name!r} mode must use forms.function_space" ) + normal = ufl.FacetNormal(self.mesh) + incident_t = tangential_trace(excitation.mode.field, normal) + test_t = tangential_trace(self.test_function, normal) linear = ( - PETSc.ScalarType(excitation.amplitude) - * ufl.inner(excitation.surface_current, self.test_function) + matched_tem_incident_coefficient(self.k0, excitation) + * ufl.inner(incident_t, test_t) * self.ds(excitation.definition.facet_tag) ) return fem.form(linear) + def uncalibrated_surface_current_rhs_form( + self, excitation: UncalibratedSurfaceCurrentExcitation + ) -> Any: + """Compile an uncalibrated weak surface load on an observation boundary. + + Port and PEC tags are rejected: this generic load has no matched term or + power-wave meaning and must remain impossible to confuse with a port. + """ + + import ufl + from dolfinx import fem + + if excitation.facet_tag not in self.tag_contract.boundaries.observation_tags: + raise ValueError( + "uncalibrated surface-current loads are allowed only on declared " + "observation tags, never on matched-port or PEC tags" + ) + if excitation.surface_current.function_space is not self.function_space: + raise ValueError("surface current must use forms.function_space") + normal = ufl.FacetNormal(self.mesh) + current_t = tangential_trace(excitation.surface_current, normal) + test_t = tangential_trace(self.test_function, normal) + return fem.form( + excitation.amplitude + * ufl.inner(current_t, test_t) + * self.ds(excitation.facet_tag) + ) + + def rhs_form( + self, + excitation: MatchedTEMPortExcitation | UncalibratedSurfaceCurrentExcitation, + ) -> Any: + """Dispatch only between explicit matched and explicit uncalibrated loads.""" + + if isinstance(excitation, MatchedTEMPortExcitation): + return self.matched_port_rhs_form(excitation) + if isinstance(excitation, UncalibratedSurfaceCurrentExcitation): + return self.uncalibrated_surface_current_rhs_form(excitation) + raise TypeError( + "excitation must be MatchedTEMPortExcitation or " + "UncalibratedSurfaceCurrentExcitation" + ) + def _piecewise_integral(integrand: Any, measure: Any, tags: tuple[int, ...]) -> Any: if not tags: @@ -99,6 +193,16 @@ def _piecewise_integral(integrand: Any, measure: Any, tags: tuple[int, ...]) -> return result +def _validate_port_facets_present( + mesh: Any, facet_tags: Any, ports: Sequence[PortDefinition] +) -> None: + local_tags = set(int(value) for value in np.asarray(facet_tags.values).tolist()) + global_tags = set().union(*mesh.comm.allgather(local_tags)) + missing = sorted(port.facet_tag for port in ports if port.facet_tag not in global_tags) + if missing: + raise ValueError(f"matched port facet tags are absent from the mesh: {missing}") + + def build_maxwell_forms( mesh: Any, cell_tags: Any, @@ -108,9 +212,15 @@ def build_maxwell_forms( config: MaxwellProblemConfig, pml_config: PMLConfig | None = None, *, + matched_ports: Sequence[PortDefinition] | None = None, initial_frequency_hz: float = 1.0e9, ) -> MaxwellForms: - """Compile one frequency-live operator form for a tagged three-dimensional mesh.""" + """Compile one frequency-live operator for a tagged three-dimensional mesh. + + Every boundary declared as a port must have exactly one matched TEM + definition. Omitting definitions fails closed rather than leaving a + port-labelled surface on the natural/PMC boundary. + """ import basix.ufl import ufl @@ -126,6 +236,11 @@ def build_maxwell_forms( unknown = sorted(set(materials.regions) - set(tag_contract.volumes.all_tags)) if unknown: raise ValueError(f"material map refers to undeclared volume tags {unknown}") + port_definitions = validate_port_definitions( + () if matched_ports is None else matched_ports, + tag_contract.boundaries.ports, + ) + _validate_port_facets_present(mesh, facet_tags, port_definitions) element = basix.ufl.element( "N1curl", mesh.basix_cell(), config.polynomial_degree @@ -136,6 +251,7 @@ def build_maxwell_forms( epsilon_r = fem.Function(coefficient_space, name="effective_epsilon_r") inverse_mu_r = fem.Function(coefficient_space, name="inverse_mu_r") k0 = fem.Constant(mesh, PETSc.ScalarType(1.0)) + absorption_shift = fem.Constant(mesh, PETSc.ScalarType(0.0)) trial = ufl.TrialFunction(function_space) test = ufl.TestFunction(function_space) @@ -148,9 +264,16 @@ def build_maxwell_forms( physical_integrand = ufl.inner(inverse_mu_r * curl_trial, curl_test) - k0**2 * ufl.inner( epsilon_r * trial, test ) + shifted_epsilon_r = epsilon_r + PETSc.ScalarType(1j) * absorption_shift + preconditioner_integrand = ufl.inner( + inverse_mu_r * curl_trial, curl_test + ) - k0**2 * ufl.inner(shifted_epsilon_r * trial, test) bilinear = _piecewise_integral( physical_integrand, dx, tag_contract.volumes.physical_tags ) + preconditioner_bilinear = _piecewise_integral( + preconditioner_integrand, dx, tag_contract.volumes.physical_tags + ) if pml_config is not None: x = ufl.SpatialCoordinate(mesh) tensor = cartesian_pml_tensor(x, k0, pml_config) @@ -158,11 +281,43 @@ def build_maxwell_forms( pml_integrand = ufl.inner( inverse_mu_r * ufl.dot(inverse_tensor, curl_trial), curl_test ) - k0**2 * ufl.inner(epsilon_r * ufl.dot(tensor, trial), test) + preconditioner_pml_integrand = ufl.inner( + inverse_mu_r * ufl.dot(inverse_tensor, curl_trial), curl_test + ) - k0**2 * ufl.inner( + shifted_epsilon_r * ufl.dot(tensor, trial), test + ) pml_part = _piecewise_integral(pml_integrand, dx, tag_contract.volumes.pml_tags) + preconditioner_pml_part = _piecewise_integral( + preconditioner_pml_integrand, dx, tag_contract.volumes.pml_tags + ) bilinear = pml_part if bilinear is None else bilinear + pml_part + preconditioner_bilinear = ( + preconditioner_pml_part + if preconditioner_bilinear is None + else preconditioner_bilinear + preconditioner_pml_part + ) if bilinear is None: raise ValueError("tag contract does not contain any integrable volume tags") + normal = ufl.FacetNormal(mesh) + trial_t = tangential_trace(trial, normal) + test_t = tangential_trace(test, normal) + for port in port_definitions: + # exp(-i omega t): the outgoing TEM branch gives + # n x mu_r^-1 curl(E) = -i k0 Z_vac Y_f E_t. Since the curl-curl + # Green identity contributes +, the Robin + # contribution to this sign convention is negative imaginary. + bilinear += ( + matched_tem_operator_coefficient(k0, port) + * ufl.inner(trial_t, test_t) + * ds(port.facet_tag) + ) + preconditioner_bilinear += ( + matched_tem_operator_coefficient(k0, port) + * ufl.inner(trial_t, test_t) + * ds(port.facet_tag) + ) + boundary_conditions: list[Any] = [] if tag_contract.boundaries.pec_tags: facets = np.unique( @@ -187,9 +342,14 @@ def build_maxwell_forms( k0=k0, epsilon_r=epsilon_r, inverse_mu_r=inverse_mu_r, + preconditioner_absorption_shift=absorption_shift, bilinear_form=fem.form(bilinear), + preconditioner_bilinear_form=fem.form(preconditioner_bilinear), boundary_conditions=boundary_conditions, ds=ds, + matched_ports=MappingProxyType( + {port.name: port for port in port_definitions} + ), _materials=materials, _frequency_hz=float(initial_frequency_hz), ) diff --git a/src/scatter3d/fem/ports.py b/src/scatter3d/fem/ports.py index 8ab86ee..475d978 100644 --- a/src/scatter3d/fem/ports.py +++ b/src/scatter3d/fem/ports.py @@ -1,69 +1,340 @@ -"""Port definitions, independent mode normalization, and modal observables.""" +"""Power-normalized single-mode TEM ports and explicitly uncalibrated loads. + +The FEM field convention is ``exp(-i omega t)``. ``PortDefinition`` keeps the +VNA/circuit reference impedance separate from the field-wave impedance used in +Maxwell's boundary condition. They have different physical roles and are not +interchangeable. + +The matched condition implemented by :mod:`scatter3d.fem.forms` assumes one +known TEM mode and a scalar, frequency-independent field-wave impedance +``Z_f``. With outward normal ``n`` and ``Y_f = 1 / Z_f``, it is + +``n x (mu_r^-1 curl(E)) + i k0 Z_vac Y_f E_t = 2 i k0 Z_vac Y_f E_inc``. + +It is a first-order, single-mode termination. It is not a waveguide eigenmode +solver, a multimode DtN map, or an automatic 50-ohm S-parameter calibration. +""" from __future__ import annotations -from dataclasses import dataclass -from math import isfinite +from collections.abc import Mapping, Sequence +from dataclasses import KW_ONLY, dataclass +from math import isfinite, sqrt +from operator import index from typing import Any +from .config import EPSILON_0, MU_0 + +VACUUM_IMPEDANCE_OHM = sqrt(MU_0 / EPSILON_0) + + +def _finite_complex(value: complex, label: str) -> complex: + result = complex(value) + if not (isfinite(result.real) and isfinite(result.imag)): + raise ValueError(f"{label} must be finite") + return result + + +def _positive_tag(value: int, label: str = "facet_tag") -> int: + try: + result = index(value) + except TypeError as exc: + raise TypeError(f"{label} must be an integer") from exc + if result <= 0: + raise ValueError(f"{label} must be a positive integer") + return result + @dataclass(frozen=True, slots=True) class PortDefinition: + """Physical contract for one matched single-mode TEM boundary. + + ``field_wave_impedance_ohm`` is the local tangential ``|E| / |H|`` ratio + used by the Maxwell boundary and Poynting normalization. It is required + and keyword-only so a 50-ohm VNA reference cannot silently take its place. + + ``circuit_reference_impedance_ohm`` defines the external circuit power-wave + reference. It is provenance for later S-parameter renormalization and does + not enter the field boundary condition. + + ``outgoing_propagation_index`` defines the spatial branch by + ``exp(i*k0*n_eff*s)`` in the outward direction. Positive real part is + required for a forward phase-propagating mode and nonnegative imaginary + part for passive attenuation under the package time convention. + + A mode is accepted only when ``Re(1 / Z_f) > 0``. This rejects zero-power + evanescent impedances and non-passive branches. A scalar impedance is an + explicit single-mode approximation; frequency-dependent or multimode ports + require a new definition/operator at each frequency. + """ + name: str facet_tag: int - reference_impedance_ohm: float = 50.0 + _: KW_ONLY + field_wave_impedance_ohm: complex + outgoing_propagation_index: complex + circuit_reference_impedance_ohm: float = 50.0 target_forward_power_w: float = 1.0 def __post_init__(self) -> None: - if not self.name.strip(): + name = str(self.name).strip() + if not name: raise ValueError("port name must not be empty") - if int(self.facet_tag) <= 0: - raise ValueError("facet_tag must be a positive integer") - impedance = float(self.reference_impedance_ohm) - power = float(self.target_forward_power_w) - if not isfinite(impedance) or impedance <= 0: - raise ValueError("reference_impedance_ohm must be finite and positive") - if not isfinite(power) or power <= 0: + tag = _positive_tag(self.facet_tag) + field_impedance = _finite_complex( + self.field_wave_impedance_ohm, "field_wave_impedance_ohm" + ) + if field_impedance == 0: + raise ValueError("field_wave_impedance_ohm must be nonzero") + try: + field_admittance = 1.0 / field_impedance + except ZeroDivisionError as exc: + raise ValueError( + "field_wave_impedance_ohm is too small to define finite admittance" + ) from exc + if not ( + isfinite(field_admittance.real) + and isfinite(field_admittance.imag) + and field_admittance.real > 0.0 + ): + raise ValueError( + "field_wave_impedance_ohm must select a passive propagating mode " + "with Re(1/Z_f) > 0; evanescent and non-passive modes are unsupported" + ) + propagation_index = _finite_complex( + self.outgoing_propagation_index, "outgoing_propagation_index" + ) + if propagation_index.real <= 0.0: + raise ValueError( + "outgoing_propagation_index must have positive real part; " + "cutoff/evanescent and backward-wave modes are unsupported" + ) + if propagation_index.imag < 0.0: + raise ValueError( + "outgoing_propagation_index must have nonnegative imaginary part " + "for the exp(i*k0*n_eff*s) passive outgoing convention" + ) + circuit_impedance = float(self.circuit_reference_impedance_ohm) + target_power = float(self.target_forward_power_w) + if not isfinite(circuit_impedance) or circuit_impedance <= 0.0: + raise ValueError( + "circuit_reference_impedance_ohm must be finite and positive" + ) + if not isfinite(target_power) or target_power <= 0.0: raise ValueError("target_forward_power_w must be finite and positive") - object.__setattr__(self, "facet_tag", int(self.facet_tag)) - object.__setattr__(self, "reference_impedance_ohm", impedance) - object.__setattr__(self, "target_forward_power_w", power) + object.__setattr__(self, "name", name) + object.__setattr__(self, "facet_tag", tag) + object.__setattr__(self, "field_wave_impedance_ohm", field_impedance) + object.__setattr__(self, "outgoing_propagation_index", propagation_index) + object.__setattr__( + self, "circuit_reference_impedance_ohm", circuit_impedance + ) + object.__setattr__(self, "target_forward_power_w", target_power) + + @property + def field_wave_admittance_siemens(self) -> complex: + return 1.0 / self.field_wave_impedance_ohm + + @property + def forward_power_admittance_siemens(self) -> float: + """Real admittance multiplying ``|E_t|^2 / 2`` in forward power.""" - def canonical(self) -> dict[str, float | int | str]: + return self.field_wave_admittance_siemens.real + + def canonical(self) -> dict[str, Any]: + field_impedance = self.field_wave_impedance_ohm return { "name": self.name, "facet_tag": self.facet_tag, - "reference_impedance_ohm": self.reference_impedance_ohm, + "field_wave_impedance_ohm": [ + field_impedance.real, + field_impedance.imag, + ], + "outgoing_propagation_index": [ + self.outgoing_propagation_index.real, + self.outgoing_propagation_index.imag, + ], + "circuit_reference_impedance_ohm": self.circuit_reference_impedance_ohm, "target_forward_power_w": self.target_forward_power_w, + "mode_model": "matched-single-tem", + "time_convention": "exp(-i*omega*t)", } @dataclass(frozen=True, slots=True) class NormalizedPortMode: + """A tangential electric mode scaled to known real forward power.""" + definition: PortDefinition field: Any - raw_power_w: float + raw_forward_power_w: float scale: float + def __post_init__(self) -> None: + if not isinstance(self.definition, PortDefinition): + raise TypeError("definition must be a PortDefinition") + raw_power = float(self.raw_forward_power_w) + scale = float(self.scale) + if not isfinite(raw_power) or raw_power <= 0.0: + raise ValueError("raw_forward_power_w must be finite and positive") + if not isfinite(scale) or scale <= 0.0: + raise ValueError("mode scale must be finite and positive") + object.__setattr__(self, "raw_forward_power_w", raw_power) + object.__setattr__(self, "scale", scale) + @dataclass(frozen=True, slots=True) -class PortExcitation: - """Equivalent surface current used as one Maxwell right-hand side. +class MatchedTEMPortExcitation: + """An inward-travelling incident TEM mode on its matched boundary. - ``surface_current`` is a DOLFINx coefficient in A/m. Its weak-form units - and sign are explicit: the solver assembles ``integral J_s dot v ds`` and - does not hide an antenna calibration factor. + ``amplitude`` multiplies a mode whose power is + ``definition.target_forward_power_w``. Thus the incident power is + ``abs(amplitude)**2 * target_forward_power_w``. """ - definition: PortDefinition + mode: NormalizedPortMode + amplitude: complex = 1.0 + 0.0j + + def __post_init__(self) -> None: + if not isinstance(self.mode, NormalizedPortMode): + raise TypeError("mode must be a NormalizedPortMode") + amplitude = _finite_complex(self.amplitude, "incident amplitude") + if amplitude == 0: + raise ValueError("incident amplitude must be nonzero") + try: + incident_power = ( + abs(amplitude) ** 2 + * self.mode.definition.target_forward_power_w + ) + except OverflowError as exc: + raise ValueError( + "incident amplitude produces nonfinite incident power" + ) from exc + if not isfinite(incident_power): + raise ValueError("incident amplitude produces nonfinite incident power") + object.__setattr__(self, "amplitude", amplitude) + + @property + def definition(self) -> PortDefinition: + return self.mode.definition + + @property + def incident_power_w(self) -> float: + return abs(self.amplitude) ** 2 * self.definition.target_forward_power_w + + +@dataclass(frozen=True, slots=True) +class UncalibratedSurfaceCurrentExcitation: + """Generic pre-scaled weak surface load, explicitly not an S-parameter port. + + ``surface_current`` retains the old generic ``inner(load, test) * ds`` path. + It has no field-wave impedance, power normalization, matched termination, or + circuit calibration. Its amplitude therefore has application-defined weak + form units and its solution must not be reported as a calibrated port result. + """ + + boundary_name: str + facet_tag: int surface_current: Any amplitude: complex = 1.0 + 0.0j + def __post_init__(self) -> None: + name = str(self.boundary_name).strip() + if not name: + raise ValueError("boundary_name must not be empty") + tag = _positive_tag(self.facet_tag) + amplitude = _finite_complex(self.amplitude, "surface-current amplitude") + if amplitude == 0: + raise ValueError("surface-current amplitude must be nonzero") + object.__setattr__(self, "boundary_name", name) + object.__setattr__(self, "facet_tag", tag) + object.__setattr__(self, "amplitude", amplitude) + + +def validate_port_definitions( + definitions: Sequence[PortDefinition], + declared_ports: Mapping[str, int], +) -> tuple[PortDefinition, ...]: + """Require a one-to-one match between physical definitions and mesh contract.""" + + ports = tuple(definitions) + if any(not isinstance(port, PortDefinition) for port in ports): + raise TypeError("matched_ports must contain only PortDefinition objects") + names = [port.name for port in ports] + tags = [port.facet_tag for port in ports] + duplicate_names = sorted({name for name in names if names.count(name) > 1}) + duplicate_tags = sorted({tag for tag in tags if tags.count(tag) > 1}) + if duplicate_names: + raise ValueError(f"matched port names are duplicated: {duplicate_names}") + if duplicate_tags: + raise ValueError(f"matched port facet tags are duplicated: {duplicate_tags}") + + declared = {str(name): int(tag) for name, tag in declared_ports.items()} + supplied = {port.name: port.facet_tag for port in ports} + missing = sorted(set(declared) - set(supplied)) + unknown = sorted(set(supplied) - set(declared)) + mismatched = sorted( + name + for name in set(declared) & set(supplied) + if declared[name] != supplied[name] + ) + if missing or unknown or mismatched: + pieces: list[str] = [] + if missing: + pieces.append(f"missing matched definitions for {missing}") + if unknown: + pieces.append(f"definitions for undeclared ports {unknown}") + if mismatched: + detail = { + name: {"declared": declared[name], "supplied": supplied[name]} + for name in mismatched + } + pieces.append(f"name/tag mismatches {detail}") + raise ValueError("; ".join(pieces)) + return ports + + +def matched_tem_operator_coefficient(k0_per_m: Any, definition: PortDefinition) -> Any: + """Return ``-i k0 Z_vac / Z_f`` for the matched bilinear form.""" + + return ( + -1.0j + * k0_per_m + * VACUUM_IMPEDANCE_OHM + * definition.field_wave_admittance_siemens + ) + + +def matched_tem_incident_coefficient( + k0_per_m: Any, excitation: MatchedTEMPortExcitation +) -> Any: + """Return ``-2 i k0 Z_vac amplitude / Z_f`` for the incident RHS.""" + + return ( + 2.0 + * excitation.amplitude + * matched_tem_operator_coefficient(k0_per_m, excitation.definition) + ) + + +def tangential_trace(field: Any, normal: Any) -> Any: + """Return the rotated H(curl) tangential trace ``field x normal``. + + Its norm and pairwise inner products equal those of the tangential field, + while avoiding an unsupported normal trace of an H(curl) function. This is + also the representation used by the official DOLFINx Maxwell demo. + """ -def _tangential(field: Any, normal: Any) -> Any: import ufl - return field - normal * ufl.dot(field, normal) + return ufl.cross(field, normal) + + +def _global_tag_count(mesh: Any, facet_tags: Any, tag: int) -> int: + from mpi4py import MPI + + local_count = int(facet_tags.find(tag).size) + return int(mesh.comm.allreduce(local_count, op=MPI.SUM)) def normalize_port_mode( @@ -71,10 +342,15 @@ def normalize_port_mode( facet_tags: Any, definition: PortDefinition, ) -> NormalizedPortMode: - """Normalize one tangential E mode to its own requested forward power. + """Normalize tangential ``E`` by real forward Poynting power. + + For the declared outgoing TEM branch, ``H = Y_f (n x E_t)`` and therefore + + ``P_forward = 1/2 Re integral(E x conj(H)) . n ds`` + `` = 1/2 Re(Y_f) integral(|E_t|^2) ds``. - The calculation is repeated independently for each port. This prevents a - common error where every antenna is scaled with port zero's mode norm. + Each port is integrated and scaled independently. The circuit reference + impedance is deliberately absent from this calculation. """ import numpy as np @@ -83,40 +359,78 @@ def normalize_port_mode( from mpi4py import MPI mesh = mode.function_space.mesh + if int(facet_tags.dim) != int(mesh.topology.dim) - 1: + raise ValueError("facet_tags must have mesh-topology dimension minus one") + if _global_tag_count(mesh, facet_tags, definition.facet_tag) == 0: + raise ValueError( + f"port {definition.name!r} facet tag {definition.facet_tag} is absent" + ) normal = ufl.FacetNormal(mesh) - tangential = _tangential(mode, normal) + tangential = tangential_trace(mode, normal) ds = ufl.Measure("ds", domain=mesh, subdomain_data=facet_tags) local_energy = fem.assemble_scalar( fem.form(ufl.inner(tangential, tangential) * ds(definition.facet_tag)) ) - energy = float(mesh.comm.allreduce(local_energy.real, op=MPI.SUM)) - raw_power = energy / (2.0 * definition.reference_impedance_ohm) - if not np.isfinite(raw_power) or raw_power <= 0: + energy = complex(mesh.comm.allreduce(local_energy, op=MPI.SUM)) + imaginary_tolerance = 1.0e-11 * max(1.0, abs(energy.real)) + if abs(energy.imag) > imaginary_tolerance: + raise ValueError( + f"port {definition.name!r} modal energy is unexpectedly complex" + ) + raw_power = 0.5 * definition.forward_power_admittance_siemens * energy.real + if not np.isfinite(raw_power) or raw_power <= 0.0: raise ValueError( - f"port {definition.name!r} has zero or invalid tangential modal power" + f"port {definition.name!r} has zero or invalid real forward modal power" ) - scale = float((definition.target_forward_power_w / raw_power) ** 0.5) + scale = float(sqrt(definition.target_forward_power_w / raw_power)) normalized = fem.Function(mode.function_space, name=f"mode_{definition.name}") normalized.x.array[:] = scale * mode.x.array normalized.x.scatter_forward() return NormalizedPortMode(definition, normalized, raw_power, scale) -def modal_overlap(solution: Any, normalized_mode: NormalizedPortMode, facet_tags: Any) -> complex: - """Return a deterministic surface overlap; calibration converts it to S.""" +def total_electric_modal_coefficient( + solution: Any, normalized_mode: NormalizedPortMode, facet_tags: Any +) -> complex: + """Project total tangential ``E`` onto one mode; this is not an S-parameter. + + Separating incident and outgoing power waves additionally requires the + magnetic/curl trace and a validated modal extraction convention. This + routine intentionally returns only the electric expansion coefficient so a + surface overlap cannot be mistaken for calibrated ``S_ij``. + """ import ufl from dolfinx import fem from mpi4py import MPI mesh = solution.function_space.mesh + if normalized_mode.field.function_space is not solution.function_space: + raise ValueError("solution and normalized mode must use the same function space") + definition = normalized_mode.definition + if _global_tag_count(mesh, facet_tags, definition.facet_tag) == 0: + raise ValueError( + f"port {definition.name!r} facet tag {definition.facet_tag} is absent" + ) normal = ufl.FacetNormal(mesh) ds = ufl.Measure("ds", domain=mesh, subdomain_data=facet_tags) - integrand = ufl.inner( - _tangential(solution, normal), - _tangential(normalized_mode.field, normal), + solution_t = tangential_trace(solution, normal) + mode_t = tangential_trace(normalized_mode.field, normal) + numerator_local = fem.assemble_scalar( + fem.form(ufl.inner(solution_t, mode_t) * ds(definition.facet_tag)) ) - local = fem.assemble_scalar( - fem.form(integrand * ds(normalized_mode.definition.facet_tag)) + denominator_local = fem.assemble_scalar( + fem.form(ufl.inner(mode_t, mode_t) * ds(definition.facet_tag)) ) - return complex(mesh.comm.allreduce(local, op=MPI.SUM)) + numerator = complex(mesh.comm.allreduce(numerator_local, op=MPI.SUM)) + denominator = complex(mesh.comm.allreduce(denominator_local, op=MPI.SUM)) + if not ( + isfinite(numerator.real) + and isfinite(numerator.imag) + and isfinite(denominator.real) + and isfinite(denominator.imag) + ): + raise ValueError(f"port {definition.name!r} modal projection is nonfinite") + if abs(denominator) == 0.0: + raise ValueError(f"port {definition.name!r} has zero modal projection norm") + return numerator / denominator diff --git a/src/scatter3d/fem/solver.py b/src/scatter3d/fem/solver.py index a1396f4..c23adca 100644 --- a/src/scatter3d/fem/solver.py +++ b/src/scatter3d/fem/solver.py @@ -2,9 +2,11 @@ from __future__ import annotations +from collections.abc import Iterable, Sequence from dataclasses import dataclass +from itertools import pairwise from time import perf_counter -from typing import Any, Iterable, Sequence +from typing import Any import numpy as np @@ -17,12 +19,15 @@ ) from .diagnostics import ( MaterialChange, + SolverHierarchyDiagnostics, compare_material_models, + inspect_petsc_solver_hierarchy, petsc_true_relative_residual, process_peak_rss_bytes, + validate_effective_solver_hierarchy, ) from .forms import MaxwellForms, build_maxwell_forms -from .ports import PortExcitation +from .ports import MatchedTEMPortExcitation, PortDefinition from .tags import MeshTagContract, validate_mesh_tags @@ -37,18 +42,46 @@ class PortSolveDiagnostics: solve_seconds: float +@dataclass(frozen=True, slots=True) +class TransferOperatorDiagnostics: + direction: str + rows: int + columns: int + nonzeros: int + memory_bytes_sum: int | None + assembly_seconds: float + constrained_fine_rows: int + constrained_coarse_columns: int + maximum_imaginary_abs: float + + @dataclass(frozen=True, slots=True) class FrequencyDiagnostics: frequency_hz: float + fine_degree: int global_complex_dofs: int local_owned_dofs: int matrix_nonzeros: int matrix_memory_bytes_sum: int | None + preconditioner_absorption_shift: float + preconditioner_operator_is_physical: bool + preconditioner_matrix_nonzeros: int + preconditioner_matrix_memory_bytes_sum: int | None + preconditioner_assembly_seconds: float + coarse_degree: int | None + coarse_global_complex_dofs: int | None + coarse_local_owned_dofs: int | None + coarse_preconditioner_matrix_nonzeros: int | None + coarse_preconditioner_matrix_memory_bytes_sum: int | None + coarse_preconditioner_assembly_seconds: float | None + transfer_operator: TransferOperatorDiagnostics | None + p_multigrid_operator_checks_passed: bool | None rank_peak_rss_bytes_max: int | None rank_peak_rss_bytes_sum: int | None assembly_seconds: float setup_seconds: float solver_path: str + solver_hierarchy: SolverHierarchyDiagnostics port_solves: tuple[PortSolveDiagnostics, ...] @@ -57,11 +90,25 @@ class SweepResult: solutions: dict[float, dict[str, Any]] diagnostics: tuple[FrequencyDiagnostics, ...] matrix_assemblies: int + preconditioner_matrix_assemblies: int + coarse_preconditioner_matrix_assemblies: int + transfer_operator_assemblies: int operator_setups: int - numeric_factorizations: int + global_numeric_factorizations: int + coarse_global_factorizations: int rhs_solves: int solver_path: str + @property + def numeric_factorizations(self) -> int: + """Backward-compatible alias for global top-level factorizations. + + Local factorizations inside ASM or future multilevel PCs are not + included in this counter. + """ + + return self.global_numeric_factorizations + @dataclass(frozen=True, slots=True) class ExperimentSweepResult: @@ -74,7 +121,7 @@ def _strict_frequencies(values: Iterable[float]) -> tuple[float, ...]: frequencies = tuple(float(value) for value in values) if not frequencies or any(not np.isfinite(value) or value <= 0 for value in frequencies): raise ValueError("frequencies_hz must contain finite positive values") - if any(right <= left for left, right in zip(frequencies, frequencies[1:])): + if any(right <= left for left, right in pairwise(frequencies)): raise ValueError("frequencies_hz must be strictly increasing") return frequencies @@ -100,15 +147,120 @@ def _rss_metrics(comm: Any) -> tuple[int | None, int | None]: from mpi4py import MPI local = process_peak_rss_bytes() - if local is None: - available = comm.allreduce(0, op=MPI.SUM) - return (None, None) if available == 0 else (None, None) + available = int(comm.allreduce(int(local is not None), op=MPI.SUM)) + if available != comm.size: + return None, None + assert local is not None return ( int(comm.allreduce(local, op=MPI.MAX)), int(comm.allreduce(local, op=MPI.SUM)), ) +def _owned_bc_local_dofs(forms: MaxwellForms) -> np.ndarray: + """Return unique owned constrained dofs in local vector numbering.""" + + owned: list[np.ndarray] = [] + for bc in forms.boundary_conditions: + local_dofs, first_ghost = bc.dof_indices() + local = np.asarray(local_dofs[:first_ghost], dtype=np.int64) + if local.size: + owned.append(local) + if not owned: + return np.empty(0, dtype=np.int32) + return np.unique(np.concatenate(owned)).astype(np.int32, copy=False) + + +def _build_masked_p_interpolation( + coarse_forms: MaxwellForms, + fine_forms: MaxwellForms, +) -> tuple[Any, TransferOperatorDiagnostics]: + """Build coarse-to-fine interpolation and remove PEC rows and columns.""" + + from dolfinx.fem import petsc as fem_petsc + from mpi4py import MPI + from petsc4py import PETSc + + comm = fine_forms.mesh.comm + start = perf_counter() + interpolation = fem_petsc.interpolation_matrix( + coarse_forms.function_space, fine_forms.function_space + ) + try: + interpolation.assemble() + coarse_mask, fine_mask = interpolation.createVecs() + try: + coarse_mask.set(1.0) + fine_mask.set(1.0) + fine_rows = _owned_bc_local_dofs(fine_forms) + coarse_columns = _owned_bc_local_dofs(coarse_forms) + local_error = None + if fine_rows.size and ( + int(fine_rows.min()) < 0 + or int(fine_rows.max()) >= fine_mask.array.size + ): + local_error = "fine PEC dof is outside the owned transfer row mask" + elif coarse_columns.size and ( + int(coarse_columns.min()) < 0 + or int(coarse_columns.max()) >= coarse_mask.array.size + ): + local_error = ( + "coarse PEC dof is outside the owned transfer column mask" + ) + rank_errors = tuple(comm.allgather(local_error)) + if any(error is not None for error in rank_errors): + raise RuntimeError(f"invalid distributed transfer mask: {rank_errors}") + fine_mask.array[fine_rows] = 0.0 + coarse_mask.array[coarse_columns] = 0.0 + interpolation.diagonalScale(fine_mask, coarse_mask) + finally: + fine_mask.destroy() + coarse_mask.destroy() + interpolation.assemble() + + fine_map = fine_forms.function_space.dofmap.index_map + fine_bs = fine_forms.function_space.dofmap.index_map_bs + coarse_map = coarse_forms.function_space.dofmap.index_map + coarse_bs = coarse_forms.function_space.dofmap.index_map_bs + expected = ( + int(fine_map.size_global * fine_bs), + int(coarse_map.size_global * coarse_bs), + ) + actual_shape = tuple(interpolation.getSize()) + if actual_shape != expected: + raise RuntimeError( + f"coarse-to-fine interpolation shape {actual_shape} != {expected}" + ) + imaginary = interpolation.duplicate(copy=True) + try: + imaginary.imagPart() + maximum_imaginary = float(imaginary.norm(PETSc.NormType.INFINITY)) + finally: + imaginary.destroy() + if maximum_imaginary != 0.0: + raise RuntimeError( + "p-multigrid interpolation must be real-valued in the complex build" + ) + nonzeros, memory = _matrix_metrics(interpolation, comm) + fine_count = int(comm.allreduce(fine_rows.size, op=MPI.SUM)) + coarse_count = int(comm.allreduce(coarse_columns.size, op=MPI.SUM)) + diagnostics = TransferOperatorDiagnostics( + direction="coarse_to_fine", + rows=expected[0], + columns=expected[1], + nonzeros=nonzeros, + memory_bytes_sum=memory, + assembly_seconds=perf_counter() - start, + constrained_fine_rows=fine_count, + constrained_coarse_columns=coarse_count, + maximum_imaginary_abs=maximum_imaginary, + ) + return interpolation, diagnostics + except Exception: + interpolation.destroy() + raise + + class MaxwellSweepSolver: """Own a compiled weak form and solve independent RHS vectors per frequency.""" @@ -119,7 +271,10 @@ def __init__( ) -> None: self.forms = forms self.solver_config = solver_config or LinearSolverConfig.direct() + self.coarse_forms: MaxwellForms | None = None self._options_counter = 0 + if self.solver_config.uses_p_multigrid: + self._ensure_coarse_forms() @classmethod def from_mesh( @@ -132,9 +287,10 @@ def from_mesh( problem_config: MaxwellProblemConfig, *, pml_config: PMLConfig | None = None, + matched_ports: Sequence[PortDefinition] | None = None, solver_config: LinearSolverConfig | None = None, initial_frequency_hz: float = 1.0e9, - ) -> "MaxwellSweepSolver": + ) -> MaxwellSweepSolver: validate_mesh_tags(mesh, cell_tags, facet_tags, tag_contract) forms = build_maxwell_forms( mesh, @@ -144,6 +300,7 @@ def from_mesh( materials, problem_config, pml_config, + matched_ports=matched_ports, initial_frequency_hz=initial_frequency_hz, ) return cls(forms, solver_config) @@ -152,13 +309,58 @@ def from_mesh( def function_space(self) -> Any: return self.forms.function_space - def _configure_ksp(self, matrix: Any) -> tuple[Any, str, list[str]]: + def _ensure_coarse_forms(self) -> MaxwellForms: + coarse_degree = self.solver_config.p_multigrid_coarse_degree + if coarse_degree is None: + raise RuntimeError("p-multigrid coarse forms requested without a coarse degree") + fine_degree = self.forms.config.polynomial_degree + if coarse_degree >= fine_degree: + raise ValueError( + "p_multigrid_coarse_degree must be below the fine polynomial degree" + ) + if ( + self.coarse_forms is not None + and self.coarse_forms.config.polynomial_degree == coarse_degree + ): + return self.coarse_forms + coarse_config = MaxwellProblemConfig( + polynomial_degree=coarse_degree, + geometry_order=self.forms.config.geometry_order, + quadrature_degree=self.forms.config.quadrature_degree, + ) + self.coarse_forms = build_maxwell_forms( + self.forms.mesh, + self.forms.cell_tags, + self.forms.facet_tags, + self.forms.tag_contract, + self.forms.materials, + coarse_config, + self.forms.pml_config, + matched_ports=tuple(self.forms.matched_ports.values()), + initial_frequency_hz=self.forms.frequency_hz, + ) + return self.coarse_forms + + def _configure_ksp( + self, + matrix: Any, + preconditioner_matrix: Any, + *, + coarse_preconditioner_matrix: Any | None = None, + interpolation: Any | None = None, + ) -> tuple[Any, str, list[str]]: from petsc4py import PETSc config = self.solver_config ksp = PETSc.KSP().create(self.forms.mesh.comm) - ksp.setOperators(matrix) + ksp.setOperators(matrix, preconditioner_matrix) ksp.setType(config.ksp_type) + side = { + "left": PETSc.PC.Side.LEFT, + "right": PETSc.PC.Side.RIGHT, + "symmetric": PETSc.PC.Side.SYMMETRIC, + }[config.preconditioning_side] + ksp.setPCSide(side) ksp.setTolerances( rtol=config.relative_tolerance, atol=config.absolute_tolerance, @@ -176,23 +378,95 @@ def _configure_ksp(self, matrix: Any) -> tuple[Any, str, list[str]]: ksp.setOptionsPrefix(prefix) options = PETSc.Options() installed: list[str] = [] - for raw_key, value in config.petsc_options.items(): - key = prefix + str(raw_key).lstrip("-") - options[key] = value - installed.append(key) - ksp.setFromOptions() - for key in installed: - del options[key] + try: + fine_smoother = None + coarse_solver = None + if config.uses_p_multigrid: + if coarse_preconditioner_matrix is None or interpolation is None: + raise ValueError( + "p-multigrid requires coarse matrix and interpolation" + ) + # PCMG otherwise defaults to the outer Amat and can silently + # replace the shifted fine hierarchy with the physical A. + pc.setUseAmat(False) + pc.setMGLevels(2) + pc.setMGType(PETSc.PC.MGType.MULTIPLICATIVE) + pc.setMGInterpolation(1, interpolation) + fine_smoother = pc.getMGSmoother(1) + fine_smoother.setOperators( + preconditioner_matrix, preconditioner_matrix + ) + # PCMG applies level smoothers with a nonzero initial guess; + # PETSc rejects KSPPREONLY in that mode. One Richardson step + # gives an explicit single application of the ASM smoother. + fine_smoother.setType("richardson") + fine_smoother.setTolerances(max_it=1) + fine_smoother.getPC().setType("asm") + coarse_solver = pc.getMGCoarseSolve() + coarse_solver.setOperators( + coarse_preconditioner_matrix, + coarse_preconditioner_matrix, + ) + coarse_solver.setType("preonly") + coarse_pc = coarse_solver.getPC() + coarse_pc.setType("lu") + coarse_pc.setFactorSolverType("mumps") + structural = { + "pc_mg_levels": 2, + "pc_mg_galerkin": "none", + "pc_mg_type": "multiplicative", + "pc_type": "mg", + "pc_use_amat": False, + } + for raw_key, value in structural.items(): + key = prefix + raw_key + options[key] = value + installed.append(key) + for raw_key, value in config.petsc_options.items(): + key = prefix + str(raw_key).lstrip("-") + options[key] = value + installed.append(key) + ksp.setFromOptions() + effective_pc_type = str(pc.getType()).lower() + if config.is_iterative and effective_pc_type in {"lu", "cholesky"}: + raise RuntimeError( + "iterative PETSc configuration resolved to a global " + f"factorizing PC {effective_pc_type!r} before setup" + ) + if effective_pc_type != config.pc_type.lower(): + raise RuntimeError( + "effective PETSc PC changed before nested hierarchy setup: " + f"{effective_pc_type!r} != {config.pc_type.lower()!r}" + ) + if fine_smoother is not None and coarse_solver is not None: + if int(pc.getMGLevels()) != 2: + raise RuntimeError("effective p-multigrid level count is not two") + # Reacquire after outer options in case PCMG rebuilt its levels. + fine_smoother = pc.getMGSmoother(1) + coarse_solver = pc.getMGCoarseSolve() + # These objects exist before options are parsed; invoke their + # supported option paths explicitly and retain all options + # until the full outer KSP setup creates ASM subdomain PCs. + fine_smoother.setFromOptions() + coarse_solver.setFromOptions() + except Exception: + for key in installed: + del options[key] + ksp.destroy() + raise if hasattr(ksp, "setErrorIfNotConverged"): - ksp.setErrorIfNotConverged(config.error_if_not_converged) - ksp.setReusePreconditioner(True) + # Always preserve PETSc's divergence reason, iteration count, and + # true residual. The public flag controls the explicit checked + # error below instead of asking PETSc to abort before diagnostics. + ksp.setErrorIfNotConverged(False) + pc.setReusePreconditioner(True) ksp.setConvergenceHistory(config.maximum_iterations + 1, reset=True) return ksp, prefix, installed def solve( self, frequencies_hz: Iterable[float], - ports: Sequence[PortExcitation], + ports: Sequence[MatchedTEMPortExcitation], *, retain_solutions: bool = True, ) -> SweepResult: @@ -200,7 +474,6 @@ def solve( from dolfinx import fem from dolfinx.fem import petsc as fem_petsc - from mpi4py import MPI from petsc4py import PETSc frequencies = _strict_frequencies(frequencies_hz) @@ -210,9 +483,9 @@ def solve( if len(set(port_names)) != len(port_names): raise ValueError("port excitation names must be unique") for port in ports: - if port.surface_current.function_space is not self.function_space: + if port.mode.field.function_space is not self.function_space: raise ValueError( - f"port {port.definition.name!r} current must use solver.function_space" + f"port {port.definition.name!r} mode must use solver.function_space" ) comm = self.forms.mesh.comm @@ -220,111 +493,298 @@ def solve( block_size = self.function_space.dofmap.index_map_bs global_dofs = int(index_map.size_global * block_size) local_dofs = int(index_map.size_local * block_size) + coarse_forms = ( + self._ensure_coarse_forms() + if self.solver_config.uses_p_multigrid + else None + ) + if coarse_forms is not None: + # Public callers may update the fine material state directly + # between solves; the low-order hierarchy must follow it exactly. + coarse_forms.set_materials(self.forms.materials) + coarse_map = coarse_forms.function_space.dofmap.index_map + coarse_bs = coarse_forms.function_space.dofmap.index_map_bs + coarse_global_dofs = int(coarse_map.size_global * coarse_bs) + coarse_local_dofs = int(coarse_map.size_local * coarse_bs) + else: + coarse_global_dofs = None + coarse_local_dofs = None all_solutions: dict[float, dict[str, Any]] = {} frequency_diagnostics: list[FrequencyDiagnostics] = [] matrix_assemblies = 0 + preconditioner_matrix_assemblies = 0 + coarse_preconditioner_matrix_assemblies = 0 + transfer_operator_assemblies = 0 operator_setups = 0 numeric_factorizations = 0 + coarse_global_factorizations = 0 rhs_solves = 0 - for frequency in frequencies: - self.forms.update_frequency(frequency) - start = perf_counter() - matrix = fem_petsc.assemble_matrix( - self.forms.bilinear_form, bcs=self.forms.boundary_conditions + interpolation = None + transfer_diagnostics = None + if coarse_forms is not None: + interpolation, transfer_diagnostics = _build_masked_p_interpolation( + coarse_forms, self.forms ) - matrix.assemble() - assembly_seconds = perf_counter() - start - matrix_assemblies += 1 - nonzeros, matrix_memory = _matrix_metrics(matrix, comm) - - ksp, _, _ = self._configure_ksp(matrix) - start = perf_counter() - ksp.setUp() - setup_seconds = perf_counter() - start - operator_setups += 1 - if self.solver_config.is_direct: - numeric_factorizations += 1 - - solutions_at_frequency: dict[str, Any] = {} - port_diagnostics: list[PortSolveDiagnostics] = [] - for excitation in ports: - linear_form = self.forms.rhs_form(excitation) - rhs = fem_petsc.assemble_vector(linear_form) - fem_petsc.apply_lifting( - rhs, - [self.forms.bilinear_form], - bcs=[self.forms.boundary_conditions], + transfer_operator_assemblies = 1 + + for frequency_index, frequency in enumerate(frequencies): + completed_frequency = False + p_multigrid_operator_checks_passed = None + matrix = None + preconditioner_matrix = None + coarse_preconditioner_matrix = None + ksp = None + try: + self.forms.update_frequency(frequency) + shift = self.solver_config.preconditioner_absorption_shift + self.forms.set_preconditioner_absorption_shift(shift) + if coarse_forms is not None: + coarse_forms.update_frequency(frequency) + coarse_forms.set_preconditioner_absorption_shift(shift) + + start = perf_counter() + matrix = fem_petsc.assemble_matrix( + self.forms.bilinear_form, bcs=self.forms.boundary_conditions ) - rhs.ghostUpdate(addv=PETSc.InsertMode.ADD, mode=PETSc.ScatterMode.REVERSE) - fem_petsc.set_bc(rhs, self.forms.boundary_conditions) - solution = fem.Function( - self.function_space, - name=f"E_{excitation.definition.name}_{frequency:g}Hz", + matrix.assemble() + assembly_seconds = perf_counter() - start + matrix_assemblies += 1 + nonzeros, matrix_memory = _matrix_metrics(matrix, comm) + + if shift == 0.0: + # Zero shift has explicit identity semantics and incurs no + # duplicate sparse matrix or assembly. + preconditioner_matrix = matrix + preconditioner_assembly_seconds = 0.0 + preconditioner_nonzeros = nonzeros + preconditioner_memory = matrix_memory + else: + start = perf_counter() + preconditioner_matrix = fem_petsc.assemble_matrix( + self.forms.preconditioner_bilinear_form, + bcs=self.forms.boundary_conditions, + ) + preconditioner_matrix.assemble() + preconditioner_assembly_seconds = perf_counter() - start + preconditioner_matrix_assemblies += 1 + preconditioner_nonzeros, preconditioner_memory = _matrix_metrics( + preconditioner_matrix, comm + ) + + coarse_nonzeros = None + coarse_memory = None + coarse_assembly_seconds = None + if coarse_forms is not None: + start = perf_counter() + coarse_preconditioner_matrix = fem_petsc.assemble_matrix( + coarse_forms.preconditioner_bilinear_form, + bcs=coarse_forms.boundary_conditions, + ) + coarse_preconditioner_matrix.assemble() + coarse_assembly_seconds = perf_counter() - start + coarse_preconditioner_matrix_assemblies += 1 + coarse_nonzeros, coarse_memory = _matrix_metrics( + coarse_preconditioner_matrix, comm + ) + + ksp, prefix, installed_options = self._configure_ksp( + matrix, + preconditioner_matrix, + coarse_preconditioner_matrix=coarse_preconditioner_matrix, + interpolation=interpolation, ) - ksp.setConvergenceHistory(reset=True) start = perf_counter() - ksp.solve(rhs, solution.x.petsc_vec) - solve_seconds = perf_counter() - start - solution.x.scatter_forward() - rhs_solves += 1 - reason = int(ksp.getConvergedReason()) - iterations = int(ksp.getIterationNumber()) - absolute, relative = petsc_true_relative_residual( - matrix, solution.x.petsc_vec, rhs + try: + # ASM creates nested KSP/PC objects during setup. Keep the + # prefixed options live until those objects consume them. + ksp.setUp() + finally: + options = PETSc.Options() + for key in installed_options: + del options[key] + setup_seconds = perf_counter() - start + operator_setups += 1 + if self.solver_config.is_direct: + numeric_factorizations += 1 + if coarse_preconditioner_matrix is not None: + live_pc = ksp.getPC() + if live_pc.getUseAmat(): + raise RuntimeError( + "PCMG resolved to Amat instead of the shifted Pmat" + ) + fine_ksp = live_pc.getMGSmoother(1) + live_outer_amat, live_outer_pmat = ksp.getOperators() + if ( + live_outer_amat.handle != matrix.handle + or live_outer_pmat.handle != preconditioner_matrix.handle + ): + raise RuntimeError( + "outer KSP did not retain the physical A and shifted P" + ) + live_fine_amat, live_fine_pmat = fine_ksp.getOperators() + if ( + live_fine_amat.handle != preconditioner_matrix.handle + or live_fine_pmat.handle != preconditioner_matrix.handle + ): + raise RuntimeError( + "PCMG fine smoother did not retain the shifted fine P matrix" + ) + coarse_ksp = live_pc.getMGCoarseSolve() + live_coarse_amat, live_coarse_pmat = coarse_ksp.getOperators() + if ( + live_coarse_amat.handle + != coarse_preconditioner_matrix.handle + or live_coarse_pmat.handle + != coarse_preconditioner_matrix.handle + ): + raise RuntimeError( + "PCMG coarse solver did not retain the explicit coarse P" + ) + live_interpolation = live_pc.getMGInterpolation(1) + if live_interpolation.handle != interpolation.handle: + raise RuntimeError( + "PCMG did not retain the supplied p-transfer operator" + ) + p_multigrid_operator_checks_passed = True + coarse_global_factorizations += 1 + hierarchy = inspect_petsc_solver_hierarchy( + ksp, self.solver_config, prefix, comm ) - history = tuple(float(value) for value in ksp.getConvergenceHistory()) - if reason <= 0 and self.solver_config.error_if_not_converged: - rhs.destroy() - ksp.destroy() - matrix.destroy() - raise RuntimeError( - f"PETSc failed for {excitation.definition.name} at {frequency:g} Hz: " - f"reason={reason}, iterations={iterations}, " - f"true_relative_residual={relative:.3e}" - ) - port_diagnostics.append( - PortSolveDiagnostics( - port_name=excitation.definition.name, - converged_reason=reason, - iterations=iterations, - true_residual_norm=absolute, - true_relative_residual=relative, - reported_residual_history=history, - solve_seconds=solve_seconds, + validate_effective_solver_hierarchy( + hierarchy, self.solver_config + ) + + solutions_at_frequency: dict[str, Any] = {} + port_diagnostics: list[PortSolveDiagnostics] = [] + for excitation in ports: + linear_form = self.forms.rhs_form(excitation) + rhs = fem_petsc.assemble_vector(linear_form) + try: + fem_petsc.apply_lifting( + rhs, + [self.forms.bilinear_form], + bcs=[self.forms.boundary_conditions], + ) + rhs.ghostUpdate( + addv=PETSc.InsertMode.ADD, + mode=PETSc.ScatterMode.REVERSE, + ) + fem_petsc.set_bc(rhs, self.forms.boundary_conditions) + solution = fem.Function( + self.function_space, + name=f"E_{excitation.definition.name}_{frequency:g}Hz", + ) + ksp.setConvergenceHistory(reset=True) + start = perf_counter() + ksp.solve(rhs, solution.x.petsc_vec) + solve_seconds = perf_counter() - start + solution.x.scatter_forward() + rhs_solves += 1 + reason = int(ksp.getConvergedReason()) + iterations = int(ksp.getIterationNumber()) + absolute, relative = petsc_true_relative_residual( + matrix, solution.x.petsc_vec, rhs + ) + history = tuple( + float(value) for value in ksp.getConvergenceHistory() + ) + if reason <= 0 and self.solver_config.error_if_not_converged: + raise RuntimeError( + f"PETSc failed for {excitation.definition.name} " + f"at {frequency:g} Hz: reason={reason}, " + f"iterations={iterations}, " + f"true_relative_residual={relative:.3e}" + ) + port_diagnostics.append( + PortSolveDiagnostics( + port_name=excitation.definition.name, + converged_reason=reason, + iterations=iterations, + true_residual_norm=absolute, + true_relative_residual=relative, + reported_residual_history=history, + solve_seconds=solve_seconds, + ) + ) + if retain_solutions: + solutions_at_frequency[excitation.definition.name] = solution + finally: + rhs.destroy() + + rss_max, rss_sum = _rss_metrics(comm) + frequency_diagnostics.append( + FrequencyDiagnostics( + frequency_hz=frequency, + fine_degree=self.forms.config.polynomial_degree, + global_complex_dofs=global_dofs, + local_owned_dofs=local_dofs, + matrix_nonzeros=nonzeros, + matrix_memory_bytes_sum=matrix_memory, + preconditioner_absorption_shift=shift, + preconditioner_operator_is_physical=( + preconditioner_matrix is matrix + ), + preconditioner_matrix_nonzeros=preconditioner_nonzeros, + preconditioner_matrix_memory_bytes_sum=preconditioner_memory, + preconditioner_assembly_seconds=preconditioner_assembly_seconds, + coarse_degree=( + None + if coarse_forms is None + else coarse_forms.config.polynomial_degree + ), + coarse_global_complex_dofs=coarse_global_dofs, + coarse_local_owned_dofs=coarse_local_dofs, + coarse_preconditioner_matrix_nonzeros=coarse_nonzeros, + coarse_preconditioner_matrix_memory_bytes_sum=coarse_memory, + coarse_preconditioner_assembly_seconds=coarse_assembly_seconds, + transfer_operator=transfer_diagnostics, + p_multigrid_operator_checks_passed=( + p_multigrid_operator_checks_passed + ), + rank_peak_rss_bytes_max=rss_max, + rank_peak_rss_bytes_sum=rss_sum, + assembly_seconds=assembly_seconds, + setup_seconds=setup_seconds, + solver_path=self.solver_config.solver_path, + solver_hierarchy=hierarchy, + port_solves=tuple(port_diagnostics), ) ) if retain_solutions: - solutions_at_frequency[excitation.definition.name] = solution - rhs.destroy() - - rss_max, rss_sum = _rss_metrics(comm) - frequency_diagnostics.append( - FrequencyDiagnostics( - frequency_hz=frequency, - global_complex_dofs=global_dofs, - local_owned_dofs=local_dofs, - matrix_nonzeros=nonzeros, - matrix_memory_bytes_sum=matrix_memory, - rank_peak_rss_bytes_max=rss_max, - rank_peak_rss_bytes_sum=rss_sum, - assembly_seconds=assembly_seconds, - setup_seconds=setup_seconds, - solver_path=self.solver_config.solver_path, - port_solves=tuple(port_diagnostics), - ) - ) - if retain_solutions: - all_solutions[frequency] = solutions_at_frequency - ksp.destroy() - matrix.destroy() + all_solutions[frequency] = solutions_at_frequency + completed_frequency = True + finally: + if ksp is not None: + ksp.destroy() + if ( + preconditioner_matrix is not None + and preconditioner_matrix is not matrix + ): + preconditioner_matrix.destroy() + if coarse_preconditioner_matrix is not None: + coarse_preconditioner_matrix.destroy() + if matrix is not None: + matrix.destroy() + if interpolation is not None and ( + not completed_frequency + or frequency_index == len(frequencies) - 1 + ): + interpolation.destroy() + interpolation = None return SweepResult( solutions=all_solutions, diagnostics=tuple(frequency_diagnostics), matrix_assemblies=matrix_assemblies, + preconditioner_matrix_assemblies=preconditioner_matrix_assemblies, + coarse_preconditioner_matrix_assemblies=( + coarse_preconditioner_matrix_assemblies + ), + transfer_operator_assemblies=transfer_operator_assemblies, operator_setups=operator_setups, - numeric_factorizations=numeric_factorizations, + global_numeric_factorizations=numeric_factorizations, + coarse_global_factorizations=coarse_global_factorizations, rhs_solves=rhs_solves, solver_path=self.solver_config.solver_path, ) @@ -332,7 +792,7 @@ def solve( def solve_material_pair( self, frequencies_hz: Iterable[float], - ports: Sequence[PortExcitation], + ports: Sequence[MatchedTEMPortExcitation], materials: ExperimentMaterials, *, retain_solutions: bool = True, @@ -341,7 +801,11 @@ def solve_material_pair( frequencies = _strict_frequencies(frequencies_hz) self.forms.set_materials(materials.reference) + if self.coarse_forms is not None: + self.coarse_forms.set_materials(materials.reference) reference = self.solve(frequencies, ports, retain_solutions=retain_solutions) self.forms.set_materials(materials.dut) + if self.coarse_forms is not None: + self.coarse_forms.set_materials(materials.dut) dut = self.solve(frequencies, ports, retain_solutions=retain_solutions) return ExperimentSweepResult(reference, dut, compare_material_models(materials)) diff --git a/src/scatter3d/fem/tags.py b/src/scatter3d/fem/tags.py index 1ef4638..aafb8fb 100644 --- a/src/scatter3d/fem/tags.py +++ b/src/scatter3d/fem/tags.py @@ -2,9 +2,10 @@ from __future__ import annotations +from collections.abc import Mapping from dataclasses import dataclass, field from types import MappingProxyType -from typing import Any, Mapping +from typing import Any import numpy as np @@ -66,7 +67,7 @@ def canonical(self) -> dict[str, Any]: class BoundaryTagContract: """Disjoint boundary groups for PEC walls, ports, and diagnostics.""" - ports: Mapping[str, int] + ports: Mapping[str, int] = field(default_factory=dict) pec_tags: tuple[int, ...] = () observation_tags: tuple[int, ...] = () diff --git a/src/scatter3d/inverse.py b/src/scatter3d/inverse.py index 159dc25..4eb225e 100644 --- a/src/scatter3d/inverse.py +++ b/src/scatter3d/inverse.py @@ -99,15 +99,15 @@ class RepeatNoiseEstimate: """Noise statistics from paired DUT-minus-reference repeats.""" mean_differential: np.ndarray - variance: np.ndarray + sample_variance: np.ndarray repeat_count: int observation_shape: tuple[int, int, int, int] - covariance: np.ndarray | None = None + sample_covariance: np.ndarray | None = None diagnostics: Mapping[str, Any] = field(default_factory=dict) def __post_init__(self) -> None: mean = _readonly(self.mean_differential, np.complex128) - variance = _readonly(self.variance, np.float64) + variance = _readonly(self.sample_variance, np.float64) expected = int(np.prod(self.observation_shape, dtype=np.int64)) if self.repeat_count < 2: raise ValueError("at least two paired repeats are required") @@ -120,7 +120,7 @@ def __post_init__(self) -> None: if not np.all(np.isfinite(variance)) or np.any(variance < 0.0): raise ValueError("variance must be finite and non-negative") - covariance = self.covariance + covariance = self.sample_covariance if covariance is not None: covariance = _readonly(covariance, np.complex128) if covariance.shape != (expected, expected): @@ -132,18 +132,32 @@ def __post_init__(self) -> None: ): raise ValueError("covariance diagonal does not match variance") object.__setattr__(self, "mean_differential", mean) - object.__setattr__(self, "variance", variance) - object.__setattr__(self, "covariance", covariance) + object.__setattr__(self, "sample_variance", variance) + object.__setattr__(self, "sample_covariance", covariance) object.__setattr__(self, "diagnostics", MappingProxyType(dict(self.diagnostics))) - def diagonal_model( + @property + def mean_variance(self) -> np.ndarray: + """Variance of ``mean_differential`` under independent paired repeats.""" + + return _readonly(self.sample_variance / self.repeat_count, np.float64) + + @property + def mean_covariance(self) -> np.ndarray | None: + """Covariance of ``mean_differential`` when dense covariance was requested.""" + + if self.sample_covariance is None: + return None + return _readonly(self.sample_covariance / self.repeat_count, np.complex128) + + def mean_diagonal_model( self, *, relative_floor: float = 1.0e-12, absolute_floor: float = 0.0, ) -> DiagonalNoiseModel: return DiagonalNoiseModel.from_variance( - self.variance, + self.mean_variance, relative_floor=relative_floor, absolute_floor=absolute_floor, ) @@ -151,16 +165,25 @@ def diagonal_model( def _repeat_stack( repeats: np.ndarray | Sequence[ScatteringDataset], *, name: str -) -> tuple[np.ndarray, tuple[int, int, int, int]]: +) -> tuple[np.ndarray, tuple[int, int, int, int], ScatteringDataset | None]: if isinstance(repeats, np.ndarray): - stack = np.asarray(repeats, dtype=np.complex128) + raw = np.asarray(repeats) + if not np.issubdtype(raw.dtype, np.complexfloating): + raise ValueError(f"{name} must use a complex dtype") + stack = np.asarray(raw, dtype=np.complex128) if stack.ndim != 5: raise ValueError( f"{name} must have shape [repeat, angle, frequency, receiver, source]" ) + if any(size <= 0 for size in stack.shape): + raise ValueError(f"{name} axes must all be non-empty") if stack.shape[3] != stack.shape[4]: raise ValueError(f"{name} receiver/source port counts differ") - return stack, tuple(int(n) for n in stack.shape[1:]) # type: ignore[return-value] + return ( + stack, + tuple(int(n) for n in stack.shape[1:]), # type: ignore[arg-type] + None, + ) datasets = tuple(repeats) if not datasets: @@ -170,7 +193,7 @@ def _repeat_stack( first = datasets[0] for index, item in enumerate(datasets[1:], start=1): assert_compatible(first, item, first_name=f"{name}[0]", second_name=f"{name}[{index}]") - return np.stack([item.s for item in datasets]), first.shape + return np.stack([item.s for item in datasets]), first.shape, first def estimate_repeat_differential_noise( @@ -187,10 +210,24 @@ def estimate_repeat_differential_noise( variance is always computed; dense covariance is opt-in and size-guarded. """ - reference, reference_shape = _repeat_stack(reference_repeats, name="reference_repeats") - dut, dut_shape = _repeat_stack(dut_repeats, name="dut_repeats") + reference, reference_shape, reference_coordinates = _repeat_stack( + reference_repeats, name="reference_repeats" + ) + dut, dut_shape, dut_coordinates = _repeat_stack(dut_repeats, name="dut_repeats") if reference.shape != dut.shape or reference_shape != dut_shape: raise ValueError("reference and DUT repeat stacks must have identical shapes") + if (reference_coordinates is None) != (dut_coordinates is None): + raise TypeError( + "reference and DUT repeats must both be raw arrays or both be " + "coordinate-bearing ScatteringDataset sequences" + ) + if reference_coordinates is not None and dut_coordinates is not None: + assert_compatible( + reference_coordinates, + dut_coordinates, + first_name="reference_repeats[0]", + second_name="dut_repeats[0]", + ) repeat_count = reference.shape[0] if repeat_count < 2: raise ValueError("at least two paired repeats are required") @@ -214,6 +251,12 @@ def estimate_repeat_differential_noise( f"dense covariance for {observations} observations is disabled; " "use diagonal whitening or raise the explicit safety limit" ) + if repeat_count <= observations: + raise ValueError( + "dense covariance is rank-deficient when paired repeat_count is not " + "greater than observation_count; use diagonal whitening or collect " + "more independent paired repeats" + ) # Each row is a repeat. This orientation produces E[x x^H], not its # elementwise conjugate, for a complex random column vector x. covariance = centered.T @ centered.conj() / (repeat_count - 1) @@ -221,10 +264,10 @@ def estimate_repeat_differential_noise( positive = variance[variance > 0.0] return RepeatNoiseEstimate( mean_differential=mean, - variance=variance, + sample_variance=variance, repeat_count=repeat_count, observation_shape=reference_shape, - covariance=covariance, + sample_covariance=covariance, diagnostics={ "pairing": "same_index", "observation_count": observations, @@ -261,7 +304,7 @@ class TSVDSolution: residual_norm: float relative_residual: float solution_norm: float - selected_condition_number: float + selected_condition_number: float | None criterion_ranks: np.ndarray criterion_values: np.ndarray singular_value_threshold: float @@ -302,7 +345,7 @@ def tsvd_solve( method: str = "gcv", rank: int | None = None, noise_norm: float | None = None, - energy_fraction: float = 0.999, + energy_fraction: float | None = None, rcond: float | None = None, ) -> TSVDSolution: """Solve a complex linear inverse problem with transparent TSVD selection. @@ -332,6 +375,10 @@ def tsvd_solve( raise ValueError("rcond must be finite and non-negative") if method != "fixed" and rank is not None: raise ValueError("rank is only valid when method='fixed'") + if method != "discrepancy" and noise_norm is not None: + raise ValueError("noise_norm is only valid when method='discrepancy'") + if method != "energy" and energy_fraction is not None: + raise ValueError("energy_fraction is only valid when method='energy'") dtype = np.result_type(a.dtype, b.dtype, np.complex128) a = np.asarray(a, dtype=dtype) @@ -350,8 +397,11 @@ def tsvd_solve( raise np.linalg.LinAlgError("no singular values exceed the requested threshold") beta = u.conj().T @ b - projected_energy = float(np.sum(np.abs(beta) ** 2)) - outside_energy = max(0.0, float(np.vdot(b, b).real) - projected_energy) + # Form the component outside the computed left singular subspace directly. + # Subtracting projected energy from ||b||^2 catastrophically cancels for a + # full-row-rank matrix and can manufacture a large false residual floor. + outside_residual = b - u @ beta + outside_energy = float(np.vdot(outside_residual, outside_residual).real) ranks = np.arange(1, available + 1, dtype=np.int64) residual_squared = np.asarray( [outside_energy + float(np.sum(np.abs(beta[k:]) ** 2)) for k in ranks], @@ -369,6 +419,8 @@ def tsvd_solve( raise ValueError(f"rank must lie between 1 and {available}") criterion_values = residual_curve elif method == "energy": + if energy_fraction is None: + energy_fraction = 0.999 if not np.isfinite(energy_fraction) or not 0.0 < energy_fraction <= 1.0: raise ValueError("energy_fraction must lie in (0, 1]") energy_curve = np.cumsum(singular_values[:available] ** 2) @@ -378,27 +430,42 @@ def tsvd_solve( elif method == "discrepancy": if noise_norm is None or not np.isfinite(noise_norm) or noise_norm < 0.0: raise ValueError("method='discrepancy' requires a finite non-negative noise_norm") - meeting = np.flatnonzero(residual_curve <= noise_norm) + # Rank zero is a meaningful discrepancy solution: if the unmodelled + # observation already lies inside the registered noise ball, fitting a + # singular vector would manufacture structure from a null experiment. + criterion_ranks = np.arange(0, available + 1, dtype=np.int64) + criterion_values = np.concatenate( + (np.asarray([float(np.linalg.norm(b))]), residual_curve) + ) + meeting = np.flatnonzero(criterion_values <= noise_norm) target_met = meeting.size > 0 - selected = int(ranks[meeting[0]]) if target_met else available - criterion_values = residual_curve + selected = int(criterion_ranks[meeting[0]]) if target_met else available else: - denominator = (a.shape[0] - ranks).astype(np.float64) ** 2 + # GCV also needs the no-fit candidate. Otherwise a null experiment is + # forced to retain at least one singular direction. + criterion_ranks = np.arange(0, available + 1, dtype=np.int64) + gcv_residual_squared = np.concatenate( + (np.asarray([float(np.vdot(b, b).real)]), residual_squared) + ) + denominator = (a.shape[0] - criterion_ranks).astype(np.float64) ** 2 criterion_values = np.divide( - residual_squared, + gcv_residual_squared, denominator, - out=np.full_like(residual_squared, np.inf), + out=np.full_like(gcv_residual_squared, np.inf), where=denominator > 0.0, ) finite = np.flatnonzero(np.isfinite(criterion_values)) selected = ( - int(ranks[finite[np.argmin(criterion_values[finite])]]) + int(criterion_ranks[finite[np.argmin(criterion_values[finite])]]) if finite.size else available ) - coefficients = beta[:selected] / singular_values[:selected] - x = vh[:selected, :].conj().T @ coefficients + if selected == 0: + x = np.zeros(a.shape[1], dtype=dtype) + else: + coefficients = beta[:selected] / singular_values[:selected] + x = vh[:selected, :].conj().T @ coefficients predicted = a @ x residual = b - predicted residual_norm = float(np.linalg.norm(residual)) @@ -408,7 +475,9 @@ def tsvd_solve( if observation_norm > 0.0 else (0.0 if residual_norm == 0.0 else float("inf")) ) - condition = float(singular_values[0] / singular_values[selected - 1]) + condition = ( + None if selected == 0 else float(singular_values[0] / singular_values[selected - 1]) + ) return TSVDSolution( x=x, predicted=predicted, diff --git a/src/scatter3d/measurement.py b/src/scatter3d/measurement.py index c16c332..5b7ad06 100644 --- a/src/scatter3d/measurement.py +++ b/src/scatter3d/measurement.py @@ -14,6 +14,7 @@ import csv import hashlib import os +import tempfile from collections.abc import Mapping, Sequence from dataclasses import dataclass, field from pathlib import Path @@ -81,7 +82,9 @@ def __post_init__(self) -> None: raise ValueError("angles_deg must match the angle axis") if len(labels) != s.shape[2]: raise ValueError("port_labels must match the receiver/source axes") - if any(not label.strip() for label in labels) or len(set(labels)) != len(labels): + if any(label != label.strip() for label in labels): + raise ValueError("port labels must not contain leading or trailing whitespace") + if any(not label for label in labels) or len(set(labels)) != len(labels): raise ValueError("port labels must be non-empty and unique") if not np.all(np.isfinite(frequencies)) or np.any(frequencies <= 0.0): raise ValueError("frequencies_hz must be finite and positive") @@ -201,10 +204,12 @@ def _strict_float(text: str, *, field_name: str, line_number: int) -> float: def _contiguous_count(indices: set[int], *, axis_name: str) -> int: if not indices: raise ValueError(f"CSV contains no {axis_name} indices") - expected = set(range(max(indices) + 1)) - if indices != expected: + # Do not materialize ``range(max_index + 1)``: one corrupt sparse index + # must not turn a validation error into an unbounded allocation. + count = len(indices) + if min(indices) != 0 or max(indices) != count - 1: raise ValueError(f"{axis_name} indices must be contiguous and start at zero") - return len(expected) + return count def _axis_record( @@ -223,9 +228,11 @@ def _csv_hashes( frequencies: np.ndarray, port_labels: Sequence[str], order_indices: np.ndarray, + *, + csv_sha256: str | None = None, ) -> dict[str, str]: return { - "csv_sha256": sha256_file(path), + "csv_sha256": sha256_file(path) if csv_sha256 is None else csv_sha256, "csv_schema_sha256": hashlib.sha256( canonical_json_bytes({"schema": CSV_SCHEMA, "columns": CSV_COLUMNS}) ).hexdigest(), @@ -263,6 +270,7 @@ def read_scattering_csv( receiver_indices: set[int] = set() source_indices: set[int] = set() + source_sha256_before = sha256_file(source) with source.open("r", encoding="utf-8-sig", newline="") as stream: reader = csv.reader(stream) try: @@ -292,11 +300,15 @@ def read_scattering_csv( ri = _strict_integer( row[4], field_name="receiver_index", line_number=line_number ) - receiver_label = row[5].strip() + receiver_label = row[5] si = _strict_integer( row[6], field_name="source_index", line_number=line_number ) - source_label = row[7].strip() + source_label = row[7] + if receiver_label != receiver_label.strip() or source_label != source_label.strip(): + raise ValueError( + f"line {line_number}: port labels must not contain leading or trailing whitespace" + ) if not receiver_label or not source_label: raise ValueError(f"line {line_number}: port labels must be non-empty") real = _strict_float(row[8], field_name="s_real", line_number=line_number) @@ -360,7 +372,17 @@ def read_scattering_csv( frequencies = np.asarray( [frequency_values[i] for i in range(nf)], dtype=np.float64 ) - hashes = _csv_hashes(source, angles, frequencies, labels, order_indices) + source_sha256_after = sha256_file(source) + if source_sha256_after != source_sha256_before: + raise OSError("scattering CSV changed while it was being parsed") + hashes = _csv_hashes( + source, + angles, + frequencies, + labels, + order_indices, + csv_sha256=source_sha256_before, + ) if expected_hashes is not None: unknown = set(expected_hashes).difference(hashes) if unknown: @@ -384,27 +406,43 @@ def write_scattering_csv( destination = Path(path) destination.parent.mkdir(parents=True, exist_ok=True) - temporary = destination.with_name(destination.name + ".tmp") - with temporary.open("w", encoding="utf-8", newline="") as stream: - writer = csv.writer(stream, lineterminator="\n") - writer.writerow(CSV_COLUMNS) - for ai, fi, ri, si in np.ndindex(dataset.shape): - value = dataset.s[ai, fi, ri, si] - writer.writerow( - ( - ai, - format(dataset.angles_deg[ai], ".17g"), - fi, - format(dataset.frequencies_hz[fi], ".17g"), - ri, - dataset.port_labels[ri], - si, - dataset.port_labels[si], - format(value.real, ".17g"), - format(value.imag, ".17g"), + temporary: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + newline="", + dir=destination.parent, + prefix=f".{destination.name}.", + suffix=".tmp", + delete=False, + ) as stream: + temporary = Path(stream.name) + writer = csv.writer(stream, lineterminator="\n") + writer.writerow(CSV_COLUMNS) + for ai, fi, ri, si in np.ndindex(dataset.shape): + value = dataset.s[ai, fi, ri, si] + writer.writerow( + ( + ai, + format(dataset.angles_deg[ai], ".17g"), + fi, + format(dataset.frequencies_hz[fi], ".17g"), + ri, + dataset.port_labels[ri], + si, + dataset.port_labels[si], + format(value.real, ".17g"), + format(value.imag, ".17g"), + ) ) - ) - os.replace(temporary, destination) + stream.flush() + os.fsync(stream.fileno()) + assert temporary is not None + os.replace(temporary, destination) + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) order = np.asarray(list(np.ndindex(dataset.shape)), dtype=np.int64) return MappingProxyType( _csv_hashes( diff --git a/src/scatter3d/pipeline.py b/src/scatter3d/pipeline.py index d43ee01..a7037f0 100644 --- a/src/scatter3d/pipeline.py +++ b/src/scatter3d/pipeline.py @@ -7,6 +7,7 @@ from __future__ import annotations +import hashlib import json import os import tempfile @@ -21,6 +22,7 @@ SCHEMA_VERSION = "scatter3d.measurement.v1" SENSITIVITY_SCHEMA_VERSION = "scatter3d.sensitivity.v1" +RECONSTRUCTION_SCHEMA_VERSION = "scatter3d.reconstruction.v1" ChannelMode = Literal["all", "transmission", "reflection"] WhiteningMode = Literal["auto", "off", "required"] @@ -30,6 +32,23 @@ def _finite_complex(name: str, value: np.ndarray) -> None: raise ValueError(f"{name} contains non-finite values") +def _exact_dtype(name: str, value: Any, dtype: np.dtype[Any] | type) -> np.ndarray: + array = np.asarray(value) + expected = np.dtype(dtype) + if array.dtype != expected: + raise ValueError(f"{name} must use dtype {expected.name}; found {array.dtype}") + return array + + +def _unicode_vector(name: str, value: Any) -> np.ndarray: + array = np.asarray(value) + if array.dtype.kind != "U": + raise ValueError(f"{name} must use a Unicode dtype") + if array.ndim != 1: + raise ValueError(f"{name} must be one-dimensional") + return array + + def _scalar_text(archive: Any, key: str) -> str: if key not in archive: raise ValueError(f"missing required key {key!r}") @@ -40,9 +59,7 @@ def _scalar_text(archive: Any, key: str) -> str: def _repeat_axis(name: str, value: np.ndarray) -> np.ndarray: - array = np.asarray(value) - if not np.issubdtype(array.dtype, np.complexfloating): - raise ValueError(f"{name} must use a complex dtype") + array = _exact_dtype(name, value, np.complex128) if array.ndim == 4: array = array[np.newaxis, ...] if array.ndim != 5: @@ -107,6 +124,20 @@ def to_dict(self) -> dict[str, Any]: return asdict(self) +@dataclass(frozen=True) +class FrequencyChannelDiagnostic: + frequency_index: int + frequency_hz: float + receiver_index: int + receiver_port: str + source_index: int + source_port: str + differential_rms: float + repeat_noise_rms: float | None + signal_to_repeat_noise: float | None + signal_to_repeat_noise_db: float | None + + @dataclass(frozen=True) class DiagnosticReport: source_sha256: str @@ -120,6 +151,8 @@ class DiagnosticReport: dut_reciprocity_rms: float unique_reference_angle_traces: int unique_dut_angle_traces: int + complex_variance_convention: str + frequency_channels: tuple[FrequencyChannelDiagnostic, ...] def to_dict(self) -> dict[str, Any]: return asdict(self) @@ -131,19 +164,33 @@ class ReconstructionReport: sensitivity_sha256: str output_path: str method: str + status: str channel_mode: str row_order: str + requested_rank: int | None selected_rank: int + available_rank: int rows_used: int voxels: int residual_norm: float relative_residual: float solve_residual_norm: float + solution_norm: float + selected_condition_number: float | None + singular_value_threshold: float + singular_values_sha256: str + criterion_sha256: str + selection_target_met: bool | None whitening_used: bool whitening_reason: str paired_repeats: int | None noise_model_sha256: str | None noise_norm_used: float | None + noise_norm_basis: str | None + energy_fraction_used: float | None + noise_relative_floor: float + noise_absolute_floor: float + overwrite_requested: bool def to_dict(self) -> dict[str, Any]: return asdict(self) @@ -155,6 +202,7 @@ def load_measurement_bundle(path: str | Path) -> MeasurementBundle: source = Path(path).expanduser().resolve() if not source.is_file(): raise FileNotFoundError(source) + source_sha256 = sha256_file(source) with np.load(source, allow_pickle=False) as archive: version = _scalar_text(archive, "schema_version") if version != SCHEMA_VERSION: @@ -165,14 +213,17 @@ def load_measurement_bundle(path: str | Path) -> MeasurementBundle: raise ValueError(f"measurement bundle is missing keys: {sorted(missing)}") reference_s = _repeat_axis("reference_s", archive["reference_s"]) dut_s = _repeat_axis("dut_s", archive["dut_s"]) - frequencies_hz = np.asarray(archive["frequencies_hz"], dtype=np.float64) - angles_deg = np.asarray(archive["angles_deg"], dtype=np.float64) - labels_array = np.asarray(archive["port_labels"]) + frequencies_hz = _exact_dtype( + "frequencies_hz", archive["frequencies_hz"], np.float64 + ) + angles_deg = _exact_dtype("angles_deg", archive["angles_deg"], np.float64) + labels_array = _unicode_vector("port_labels", archive["port_labels"]) + + if sha256_file(source) != source_sha256: + raise OSError("measurement bundle changed while it was being parsed") if reference_s.shape[1:] != dut_s.shape[1:]: raise ValueError("reference_s and dut_s measurement axes do not match") - if labels_array.ndim != 1: - raise ValueError("port_labels must be one-dimensional") port_labels = tuple(str(item) for item in labels_array.tolist()) # ScatteringDataset is the single source of truth for coordinate validation. @@ -195,7 +246,7 @@ def load_measurement_bundle(path: str | Path) -> MeasurementBundle: angles_deg=angles_deg, port_labels=port_labels, source_path=source, - source_sha256=sha256_file(source), + source_sha256=source_sha256, ) @@ -225,6 +276,24 @@ def _repeat_noise_rms(value: np.ndarray) -> float | None: return float(np.sqrt(variance)) +def _frequency_channel_noise_rms(value: np.ndarray) -> np.ndarray | None: + """Return repeat RMS by [frequency, receiver, source], averaging angles.""" + + if value.shape[0] < 2: + return None + residual = value - np.mean(value, axis=0, keepdims=True) + sample_variance = np.sum(np.abs(residual) ** 2, axis=0) / (value.shape[0] - 1) + return np.sqrt(np.mean(sample_variance, axis=0)) + + +def _ratio_metrics(signal: float, noise: float | None) -> tuple[float | None, float | None]: + if noise is None or noise <= 0.0: + return None, None + ratio = float(signal / noise) + ratio_db = float(20.0 * np.log10(ratio)) if ratio > 0.0 else None + return ratio, ratio_db + + def _reciprocity_rms(value: np.ndarray) -> float: difference = value - np.swapaxes(value, -2, -1) return float(np.sqrt(np.mean(np.abs(difference) ** 2))) @@ -249,10 +318,48 @@ def diagnose_measurement_bundle(path: str | Path) -> DiagnosticReport: noise = ratio = ratio_db = None else: noise = float(np.hypot(ref_noise, dut_noise)) - ratio = float(differential_rms / noise) if noise > 0.0 else None - ratio_db = ( - float(20.0 * np.log10(ratio)) if ratio is not None and ratio > 0.0 else None - ) + ratio, ratio_db = _ratio_metrics(differential_rms, noise) + + differential_by_channel = np.sqrt(np.mean(np.abs(differential) ** 2, axis=0)) + reference_noise_by_channel = _frequency_channel_noise_rms(bundle.reference_s) + dut_noise_by_channel = _frequency_channel_noise_rms(bundle.dut_s) + channel_reports: list[FrequencyChannelDiagnostic] = [] + for frequency_index, frequency_hz in enumerate(bundle.frequencies_hz): + for receiver_index, receiver_port in enumerate(bundle.port_labels): + for source_index, source_port in enumerate(bundle.port_labels): + channel_signal = float( + differential_by_channel[frequency_index, receiver_index, source_index] + ) + if reference_noise_by_channel is None or dut_noise_by_channel is None: + channel_noise = None + else: + channel_noise = float( + np.hypot( + reference_noise_by_channel[ + frequency_index, receiver_index, source_index + ], + dut_noise_by_channel[ + frequency_index, receiver_index, source_index + ], + ) + ) + channel_ratio, channel_ratio_db = _ratio_metrics( + channel_signal, channel_noise + ) + channel_reports.append( + FrequencyChannelDiagnostic( + frequency_index=frequency_index, + frequency_hz=float(frequency_hz), + receiver_index=receiver_index, + receiver_port=receiver_port, + source_index=source_index, + source_port=source_port, + differential_rms=channel_signal, + repeat_noise_rms=channel_noise, + signal_to_repeat_noise=channel_ratio, + signal_to_repeat_noise_db=channel_ratio_db, + ) + ) return DiagnosticReport( source_sha256=bundle.source_sha256, reference_repeats=int(bundle.reference_s.shape[0]), @@ -265,6 +372,10 @@ def diagnose_measurement_bundle(path: str | Path) -> DiagnosticReport: dut_reciprocity_rms=_reciprocity_rms(dut), unique_reference_angle_traces=_unique_angle_traces(reference), unique_dut_angle_traces=_unique_angle_traces(dut), + complex_variance_convention=( + "unbiased E[abs(z-mean)^2] per complex sample; RMS=sqrt(variance)" + ), + frequency_channels=tuple(channel_reports), ) @@ -284,9 +395,12 @@ def _check_sensitivity_coordinates(archive: Any, bundle: MeasurementBundle) -> N for key in ("frequencies_hz", "angles_deg", "port_labels"): if key not in archive: raise ValueError(f"sensitivity archive is missing coordinate key {key!r}") - frequencies = np.asarray(archive["frequencies_hz"], dtype=np.float64) - angles = np.asarray(archive["angles_deg"], dtype=np.float64) - labels = tuple(str(item) for item in np.asarray(archive["port_labels"]).tolist()) + frequencies = _exact_dtype( + "sensitivity frequencies_hz", archive["frequencies_hz"], np.float64 + ) + angles = _exact_dtype("sensitivity angles_deg", archive["angles_deg"], np.float64) + labels_array = _unicode_vector("sensitivity port_labels", archive["port_labels"]) + labels = tuple(str(item) for item in labels_array.tolist()) if not np.array_equal(frequencies, bundle.frequencies_hz): raise ValueError("sensitivity and measurement frequency grids differ") if not np.array_equal(angles, bundle.angles_deg): @@ -340,9 +454,9 @@ def _repeat_whitening( from .inverse import DiagonalNoiseModel, estimate_repeat_differential_noise estimate = estimate_repeat_differential_noise(bundle.reference_s, bundle.dut_s) - # The estimator returns variance of individual paired differentials. The - # inverted observation is their mean, whose variance is smaller by R. - mean_variance = estimate.variance / estimate.repeat_count + # The inverted observation is the mean paired differential, so use its + # explicitly named variance rather than the individual-sample variance. + mean_variance = estimate.mean_variance if not np.any(mean_variance > 0.0) and absolute_floor <= 0.0: reason = "all paired-repeat variances are zero and no absolute floor was supplied" if mode == "required": @@ -369,19 +483,28 @@ def reconstruct_from_bundle( method: Literal["fixed", "gcv", "discrepancy", "energy"] = "gcv", rank: int | None = None, noise_norm: float | None = None, - energy_fraction: float = 0.999, + energy_fraction: float | None = None, whitening: WhiteningMode = "auto", noise_relative_floor: float = 1.0e-12, noise_absolute_floor: float = 0.0, + overwrite: bool = False, ) -> ReconstructionReport: """Form a same-index differential and solve a coordinate-checked TSVD.""" from .inverse import DiagonalNoiseModel, tsvd_solve, whiten_system + if not np.isfinite(noise_relative_floor) or noise_relative_floor < 0.0: + raise ValueError("noise_relative_floor must be finite and non-negative") + if not np.isfinite(noise_absolute_floor) or noise_absolute_floor < 0.0: + raise ValueError("noise_absolute_floor must be finite and non-negative") bundle = load_measurement_bundle(bundle_path) sensitivity_source = Path(sensitivity_path).expanduser().resolve() if not sensitivity_source.is_file(): raise FileNotFoundError(sensitivity_source) + output = Path(output_path).expanduser().resolve() + if output.exists() and not overwrite: + raise FileExistsError(f"refusing to overwrite existing reconstruction: {output}") + sensitivity_sha256 = sha256_file(sensitivity_source) reference = bundle.mean_dataset("reference") dut = bundle.mean_dataset("dut") @@ -398,13 +521,16 @@ def reconstruct_from_bundle( if "A" not in archive: raise ValueError("sensitivity archive is missing matrix key 'A'") _check_sensitivity_coordinates(archive, bundle) - A = np.asarray(archive["A"], dtype=np.complex128) + A = _exact_dtype("A", archive["A"], np.complex128) row_indices = ( - np.asarray(archive["row_indices"], dtype=np.int64) + _exact_dtype("row_indices", archive["row_indices"], np.int64) if "row_indices" in archive else np.arange(bundle.full_row_count, dtype=np.int64) ) + if sha256_file(sensitivity_source) != sensitivity_sha256: + raise OSError("sensitivity archive changed while it was being parsed") + if A.ndim != 2: raise ValueError("A must be a two-dimensional complex matrix") _finite_complex("A", A) @@ -442,16 +568,22 @@ def reconstruct_from_bundle( A_solve, b_solve = A_used, b_used effective_noise_norm = noise_norm + noise_norm_basis = "user_supplied" if noise_norm is not None else None if method == "discrepancy" and effective_noise_norm is None and selected_noise is not None: # With E|z_i|^2 = 1 after whitening, E||z||^2 equals the row count. + # This is an expected RMS scale, not a high-confidence statistical bound. effective_noise_norm = float(np.sqrt(b_solve.size)) + noise_norm_basis = "whitened_expected_rms_sqrt_rows" + effective_energy_fraction = ( + 0.999 if method == "energy" and energy_fraction is None else energy_fraction + ) solution = tsvd_solve( A_solve, b_solve, method=method, rank=rank, noise_norm=effective_noise_norm, - energy_fraction=energy_fraction, + energy_fraction=effective_energy_fraction, ) estimate = _solution_vector(solution) selected_rank = int(solution.selected_rank) @@ -460,23 +592,53 @@ def reconstruct_from_bundle( relative_residual = residual_norm / b_norm if b_norm > 0.0 else residual_norm solve_residual_norm = float(np.linalg.norm(A_solve @ estimate - b_solve)) noise_model_sha256 = sha256_array(selected_noise) if selected_noise is not None else None + singular_values_sha256 = sha256_array(solution.singular_values) + criterion_sha256 = hashlib.sha256( + ( + sha256_array(solution.criterion_ranks) + + ":" + + sha256_array(solution.criterion_values) + ).encode("ascii") + ).hexdigest() + status = "FAILED" if solution.target_met is False else "PASSED" - output = Path(output_path).expanduser().resolve() output.parent.mkdir(parents=True, exist_ok=True) with tempfile.NamedTemporaryFile(dir=output.parent, suffix=".npz", delete=False) as handle: temporary = Path(handle.name) try: np.savez_compressed( temporary, - schema_version=np.array("scatter3d.reconstruction.v1"), + schema_version=np.array(RECONSTRUCTION_SCHEMA_VERSION), estimate=estimate, + status=np.array(status), + requested_rank=np.array(-1 if rank is None else rank, dtype=np.int64), selected_rank=np.array(selected_rank, dtype=np.int64), + available_rank=np.array(solution.available_rank, dtype=np.int64), method=np.array(method), channel_mode=np.array(channel_mode), row_order=np.array("C:[angle,frequency,receiver,source]"), residual_norm=np.array(residual_norm, dtype=np.float64), relative_residual=np.array(relative_residual, dtype=np.float64), solve_residual_norm=np.array(solve_residual_norm, dtype=np.float64), + solution_norm=np.array(solution.solution_norm, dtype=np.float64), + selected_condition_number=np.array( + np.nan + if solution.selected_condition_number is None + else solution.selected_condition_number, + dtype=np.float64, + ), + singular_value_threshold=np.array( + solution.singular_value_threshold, dtype=np.float64 + ), + singular_values=solution.singular_values, + singular_values_sha256=np.array(singular_values_sha256), + criterion_ranks=solution.criterion_ranks, + criterion_values=solution.criterion_values, + criterion_sha256=np.array(criterion_sha256), + selection_target_met=np.array( + -1 if solution.target_met is None else int(solution.target_met), + dtype=np.int8, + ), whitening_used=np.array(selected_noise is not None), whitening_reason=np.array(whitening_decision.reason), paired_repeats=np.array( @@ -490,8 +652,18 @@ def reconstruct_from_bundle( noise_norm_used=np.array( np.nan if effective_noise_norm is None else effective_noise_norm, dtype=np.float64 ), + noise_norm_basis=np.array(noise_norm_basis or ""), + energy_fraction_used=np.array( + np.nan + if effective_energy_fraction is None + else effective_energy_fraction, + dtype=np.float64, + ), + noise_relative_floor=np.array(noise_relative_floor, dtype=np.float64), + noise_absolute_floor=np.array(noise_absolute_floor, dtype=np.float64), + overwrite_requested=np.array(overwrite), bundle_sha256=np.array(bundle.source_sha256), - sensitivity_sha256=np.array(sha256_file(sensitivity_source)), + sensitivity_sha256=np.array(sensitivity_sha256), row_indices=row_indices[keep], ) os.replace(temporary, output) @@ -500,29 +672,47 @@ def reconstruct_from_bundle( return ReconstructionReport( bundle_sha256=bundle.source_sha256, - sensitivity_sha256=sha256_file(sensitivity_source), + sensitivity_sha256=sensitivity_sha256, output_path=str(output), method=method, + status=status, channel_mode=channel_mode, row_order="C:[angle,frequency,receiver,source]", + requested_rank=rank, selected_rank=selected_rank, + available_rank=int(solution.available_rank), rows_used=int(A_used.shape[0]), voxels=int(A_used.shape[1]), residual_norm=residual_norm, relative_residual=relative_residual, solve_residual_norm=solve_residual_norm, + solution_norm=float(solution.solution_norm), + selected_condition_number=solution.selected_condition_number, + singular_value_threshold=float(solution.singular_value_threshold), + singular_values_sha256=singular_values_sha256, + criterion_sha256=criterion_sha256, + selection_target_met=solution.target_met, whitening_used=selected_noise is not None, whitening_reason=whitening_decision.reason, paired_repeats=whitening_decision.paired_repeats, noise_model_sha256=noise_model_sha256, noise_norm_used=effective_noise_norm, + noise_norm_basis=noise_norm_basis, + energy_fraction_used=effective_energy_fraction, + noise_relative_floor=float(noise_relative_floor), + noise_absolute_floor=float(noise_absolute_floor), + overwrite_requested=overwrite, ) -def write_json_report(report: Any, path: str | Path) -> Path: +def write_json_report( + report: Any, path: str | Path, *, overwrite: bool = False +) -> Path: """Atomically write an object exposing ``to_dict`` as UTF-8 JSON.""" destination = Path(path).expanduser().resolve() + if destination.exists() and not overwrite: + raise FileExistsError(f"refusing to overwrite existing report: {destination}") destination.parent.mkdir(parents=True, exist_ok=True) payload = report.to_dict() if hasattr(report, "to_dict") else report encoded = json.dumps(payload, indent=2, sort_keys=True, allow_nan=False) + "\n" @@ -555,12 +745,32 @@ def schema_description() -> dict[str, Any]: "schema_version": SENSITIVITY_SCHEMA_VERSION, "required_keys": { "A": "complex128 [measurement_row, voxel]", - "frequencies_hz": "must exactly equal the measurement grid", - "angles_deg": "must exactly equal the measurement grid", - "port_labels": "must exactly equal the measurement labels", + "frequencies_hz": "float64; must exactly equal the measurement grid", + "angles_deg": "float64; must exactly equal the measurement grid", + "port_labels": "Unicode; must exactly equal the measurement labels", }, "optional_keys": { "row_indices": "int64 rows into C-order [angle, frequency, receiver, source]", }, }, + "reconstruction": { + "schema_version": RECONSTRUCTION_SCHEMA_VERSION, + "required_keys": { + "estimate": "complex128 [voxel]", + "status": "PASSED or FAILED", + "method": "fixed, gcv, discrepancy, or energy", + "requested_rank": "int64; -1 when not requested", + "selected_rank": "int64 retained TSVD rank", + "available_rank": "int64 numerical rank above threshold", + "singular_values": "float64 full singular spectrum", + "criterion_ranks": "int64 selector candidate ranks", + "criterion_values": "float64 selector curve", + "selection_target_met": "int8: -1 not applicable, 0 failed, 1 passed", + "noise_standard_deviation": "float64 selected-row mean-noise scale", + "row_indices": "int64 canonical measurement rows used", + "bundle_sha256": "measurement archive content hash", + "sensitivity_sha256": "sensitivity archive content hash", + }, + "overwrite_default": False, + }, } diff --git a/src/scatter3d/provenance.py b/src/scatter3d/provenance.py index 5dc5b39..8b5bcaa 100644 --- a/src/scatter3d/provenance.py +++ b/src/scatter3d/provenance.py @@ -12,6 +12,7 @@ import json import math import os +import tempfile from collections.abc import Mapping from dataclasses import fields, is_dataclass from pathlib import Path @@ -189,9 +190,23 @@ def write_manifest(path: str | Path, manifest: Mapping[str, Any]) -> str: destination = Path(path) destination.parent.mkdir(parents=True, exist_ok=True) - temporary = destination.with_name(destination.name + ".tmp") - temporary.write_bytes(canonical_json_bytes(normalized)) - os.replace(temporary, destination) + temporary: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="wb", + dir=destination.parent, + prefix=f".{destination.name}.", + suffix=".tmp", + delete=False, + ) as stream: + temporary = Path(stream.name) + stream.write(canonical_json_bytes(normalized)) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, destination) + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) return expected diff --git a/tests/test_fem_checkpoints.py b/tests/test_fem_checkpoints.py index 5c7de4e..3580529 100644 --- a/tests/test_fem_checkpoints.py +++ b/tests/test_fem_checkpoints.py @@ -1,8 +1,14 @@ from __future__ import annotations +import json from dataclasses import replace -from scatter3d.fem.checkpoints import CheckpointIdentity, checkpoint_fingerprint +from scatter3d.fem.checkpoints import ( + CheckpointIdentity, + checkpoint_fingerprint, + verify_manifest, + write_manifest, +) def _identity() -> CheckpointIdentity: @@ -30,3 +36,16 @@ def test_checkpoint_identity_changes_with_dut_material() -> None: identity = _identity() changed = replace(identity, material_model={"reference": {}, "dut": {"epsr": 2.2}}) assert checkpoint_fingerprint(identity) != checkpoint_fingerprint(changed) + + +def test_checkpoint_manifest_rejects_payload_tamper(tmp_path) -> None: + path = tmp_path / "checkpoint.json" + identity = _identity() + write_manifest(path, identity) + assert verify_manifest(path, identity) + assert not list(tmp_path.glob("*.tmp")) + + payload = json.loads(path.read_text(encoding="utf-8")) + payload["mpi_size"] = 999 + path.write_text(json.dumps(payload), encoding="utf-8") + assert not verify_manifest(path, identity) diff --git a/tests/test_fem_coax.py b/tests/test_fem_coax.py new file mode 100644 index 0000000..110d9b2 --- /dev/null +++ b/tests/test_fem_coax.py @@ -0,0 +1,405 @@ +from __future__ import annotations + +import cmath +import json +import math + +import numpy as np +import pytest + +from scatter3d.fem.coax import ( + VACUUM_PERMEABILITY_H_PER_M, + VACUUM_PERMITTIVITY_F_PER_M, + ABCDMatrix, + CoaxialCable, + ComplexValue, + Termination, + cascade_abcd, + circular_phase_error_deg, + coax_line_parameters, + complex_s_error_metrics, + input_reflection, + terminated_input_impedance, + uniform_line_abcd, +) + + +def _lossless_cable( + *, relative_permittivity: float = 1.0, radius_ratio: float = 3.0 +) -> CoaxialCable: + return CoaxialCable( + inner_radius_m=1.0e-3, + outer_radius_m=radius_ratio * 1.0e-3, + relative_permittivity=relative_permittivity, + ) + + +def test_lossless_matched_line_has_zero_input_reflection() -> None: + parameters = coax_line_parameters(_lossless_cable(), frequency_hz=1.0e9) + network = uniform_line_abcd(parameters, length_m=0.137) + result = input_reflection( + network, + Termination.matched(parameters.z0), + reference_impedance_ohm=parameters.z0.real, + ) + + assert parameters.attenuation_np_per_m == pytest.approx(0.0, abs=1.0e-15) + assert parameters.z0.imag == pytest.approx(0.0, abs=1.0e-15) + assert result.gamma == pytest.approx(0.0j, abs=2.0e-15) + assert result.input_impedance_ohm is not None + assert result.input_impedance_ohm.value == pytest.approx(parameters.z0, rel=1.0e-14) + + +def test_lossy_dielectric_parameters_match_closed_form_tem_limit() -> None: + loss_tangent = 0.04 + parameters = coax_line_parameters( + CoaxialCable( + inner_radius_m=1.0e-3, + outer_radius_m=3.0e-3, + relative_permittivity=2.5, + loss_tangent=loss_tangent, + ), + frequency_hz=2.3e9, + ) + loss_factor = cmath.sqrt(1.0 + 1.0j * loss_tangent) + lossless_beta = parameters.angular_frequency_rad_s * math.sqrt( + parameters.inductance_h_per_m * parameters.capacitance_f_per_m + ) + expected_gamma = -1.0j * lossless_beta * loss_factor + expected_z0 = ( + math.sqrt(parameters.inductance_h_per_m / parameters.capacitance_f_per_m) + / loss_factor + ) + + assert parameters.gamma == pytest.approx(expected_gamma, rel=2.0e-14) + assert parameters.z0 == pytest.approx(expected_z0, rel=2.0e-14) + assert parameters.attenuation_np_per_m > 0.0 + assert parameters.z0.imag < 0.0 + + +def test_geometry_and_material_parameters_match_independent_closed_forms() -> None: + frequency_hz = 1.7e9 + radius_ratio = 4.25 + cable = CoaxialCable( + inner_radius_m=0.8e-3, + outer_radius_m=0.8e-3 * radius_ratio, + relative_permittivity=2.7, + relative_permeability=1.3, + loss_tangent=0.018, + dielectric_conductivity_s_per_m=2.1e-4, + series_resistance_ohm_per_m=0.37, + ) + parameters = coax_line_parameters(cable, frequency_hz) + log_ratio = math.log(radius_ratio) + expected_inductance = ( + VACUUM_PERMEABILITY_H_PER_M * cable.relative_permeability * log_ratio / math.tau + ) + expected_capacitance = ( + math.tau + * VACUUM_PERMITTIVITY_F_PER_M + * cable.relative_permittivity + / log_ratio + ) + expected_conductance = ( + math.tau * cable.dielectric_conductivity_s_per_m / log_ratio + + math.tau * frequency_hz * expected_capacitance * cable.loss_tangent + ) + series_impedance = complex( + cable.series_resistance_ohm_per_m, + -parameters.angular_frequency_rad_s * expected_inductance, + ) + shunt_admittance = complex( + expected_conductance, + -parameters.angular_frequency_rad_s * expected_capacitance, + ) + expected_gamma = cmath.sqrt(series_impedance * shunt_admittance) + expected_z0 = cmath.sqrt(series_impedance / shunt_admittance) + + assert parameters.inductance_h_per_m == pytest.approx(expected_inductance, rel=2.0e-15) + assert parameters.capacitance_f_per_m == pytest.approx(expected_capacitance, rel=2.0e-15) + assert parameters.shunt_conductance_s_per_m == pytest.approx( + expected_conductance, rel=2.0e-15 + ) + assert parameters.gamma == pytest.approx(expected_gamma, rel=3.0e-15) + assert parameters.z0 == pytest.approx(expected_z0, rel=3.0e-15) + + +def test_conductor_loss_has_passive_gamma_and_expected_impedance_sign() -> None: + parameters = coax_line_parameters( + CoaxialCable( + inner_radius_m=1.0e-3, + outer_radius_m=3.0e-3, + series_resistance_ohm_per_m=4.0, + ), + 50.0e6, + ) + + assert parameters.gamma.real > 0.0 + assert parameters.gamma.imag < 0.0 + assert parameters.z0.real > 0.0 + assert parameters.z0.imag > 0.0 + + +def test_lossless_short_has_unit_magnitude_and_analytic_phase() -> None: + parameters = coax_line_parameters(_lossless_cable(relative_permittivity=2.25), 2.0e9) + length_m = 0.031 + network = uniform_line_abcd(parameters, length_m) + result = input_reflection( + network, + Termination.short(), + reference_impedance_ohm=parameters.z0.real, + ) + expected = -cmath.exp(2.0j * parameters.phase_constant_rad_per_m * length_m) + + assert result.magnitude == pytest.approx(1.0, abs=2.0e-14) + assert result.gamma == pytest.approx(expected, abs=2.0e-14) + assert circular_phase_error_deg(result.gamma, expected) == pytest.approx(0.0, abs=1.0e-12) + + +def test_quarter_wave_line_transforms_load_impedance() -> None: + parameters = coax_line_parameters(_lossless_cable(radius_ratio=4.0), 900.0e6) + quarter_wave_length = math.pi / (2.0 * parameters.phase_constant_rad_per_m) + network = uniform_line_abcd(parameters, quarter_wave_length) + load_ohm = 80.0 + input_impedance = terminated_input_impedance(network, Termination.impedance(load_ohm)) + + assert input_impedance is not None + assert input_impedance == pytest.approx(parameters.z0**2 / load_ohm, rel=2.0e-14) + assert network.determinant == pytest.approx(1.0 + 0.0j, abs=2.0e-14) + + +def test_lossy_line_open_short_and_matched_inputs_match_closed_forms() -> None: + parameters = coax_line_parameters( + CoaxialCable( + inner_radius_m=0.6e-3, + outer_radius_m=2.8e-3, + relative_permittivity=2.2, + loss_tangent=0.025, + series_resistance_ohm_per_m=0.8, + ), + 1.4e9, + ) + length_m = 0.083 + network = uniform_line_abcd(parameters, length_m) + tanh_electrical_length = cmath.tanh(parameters.gamma * length_m) + + short_input = terminated_input_impedance(network, Termination.short()) + open_input = terminated_input_impedance(network, Termination.open()) + matched_input = terminated_input_impedance(network, Termination.matched(parameters.z0)) + + assert short_input == pytest.approx( + parameters.z0 * tanh_electrical_length, rel=3.0e-14 + ) + assert open_input == pytest.approx( + parameters.z0 / tanh_electrical_length, rel=3.0e-14 + ) + assert matched_input == pytest.approx(parameters.z0, rel=3.0e-14) + assert network.determinant == pytest.approx(1.0 + 0.0j, abs=3.0e-14) + + +def test_asymmetric_two_section_cascade_changes_when_direction_reverses() -> None: + frequency_hz = 1.2e9 + first = uniform_line_abcd( + coax_line_parameters( + _lossless_cable(relative_permittivity=4.0, radius_ratio=2.2), frequency_hz + ), + 0.019, + ) + second = uniform_line_abcd( + coax_line_parameters( + _lossless_cable(relative_permittivity=1.4, radius_ratio=5.5), frequency_hz + ), + 0.043, + ) + forward = cascade_abcd(first, second) + reverse = cascade_abcd(second, first) + forward_reflection = input_reflection(forward, Termination.impedance(73.0), 50.0).gamma + reverse_reflection = input_reflection(reverse, Termination.impedance(73.0), 50.0).gamma + + assert pytest.approx(forward.A) == first.A * second.A + first.B * second.C + assert pytest.approx(forward.B) == first.A * second.B + first.B * second.D + assert abs(forward_reflection - reverse_reflection) > 0.05 + + +def test_phase_errors_wrap_across_plus_minus_180_degrees() -> None: + expected = np.exp(1.0j * np.deg2rad(np.array([179.0, -179.0]))) + actual = np.exp(1.0j * np.deg2rad(np.array([-179.0, 179.0]))) + metrics = complex_s_error_metrics(actual, expected) + + assert circular_phase_error_deg(actual[0], expected[0]) == pytest.approx(2.0) + assert metrics.phase_sample_count == 2 + assert metrics.phase_mae_deg == pytest.approx(2.0) + assert metrics.phase_rmse_deg == pytest.approx(2.0) + assert metrics.max_abs_phase_error_deg == pytest.approx(2.0) + + +def test_zero_magnitude_samples_are_excluded_only_from_phase_metrics() -> None: + metrics = complex_s_error_metrics( + np.array([0.0j, 1.0 + 0.0j]), + np.array([0.0j, 1.0j]), + ) + + assert metrics.sample_count == 2 + assert metrics.phase_sample_count == 1 + assert metrics.phase_mae_deg == pytest.approx(90.0) + assert metrics.complex_rmse == pytest.approx(1.0) + + +def test_tiny_nonzero_samples_preserve_phase_and_normalized_error() -> None: + actual = 2.0e-300j + expected = 1.0e-300 + 0.0j + metrics = complex_s_error_metrics([actual], [expected]) + + assert circular_phase_error_deg(actual, expected) == pytest.approx(90.0) + assert metrics.phase_mae_deg == pytest.approx(90.0) + assert metrics.complex_rmse == pytest.approx(math.sqrt(5.0) * 1.0e-300) + assert metrics.normalized_complex_rmse == pytest.approx(math.sqrt(5.0)) + + +def test_antipodal_phase_uses_a_canonical_positive_180_degrees() -> None: + assert circular_phase_error_deg(-1.0 + 0.0j, 1.0 + 0.0j) == 180.0 + assert circular_phase_error_deg(1.0 + 0.0j, -1.0 + 0.0j) == 180.0 + metrics = complex_s_error_metrics([-1.0 + 0.0j], [1.0 + 0.0j]) + assert metrics.phase_mae_deg == 180.0 + + +def test_results_are_strict_json_serializable_and_deterministic() -> None: + def serialized_result() -> str: + parameters = coax_line_parameters(_lossless_cable(), 1.0e9) + network = uniform_line_abcd(parameters, 0.01) + result = input_reflection(network, Termination.open(), parameters.z0.real) + metrics = complex_s_error_metrics([result.gamma], [result.gamma]) + return json.dumps( + { + "parameters": parameters.to_dict(), + "network": network.to_dict(), + "reflection": result.to_dict(), + "metrics": metrics.to_dict(), + }, + allow_nan=False, + separators=(",", ":"), + ) + + first = serialized_result() + second = serialized_result() + assert first == second + + +def test_zero_reflection_reports_undefined_phase_as_json_null() -> None: + result = input_reflection(ABCDMatrix.identity(), Termination.matched(50.0), 50.0) + + assert result.gamma == 0.0j + assert result.magnitude == 0.0 + assert result.phase_deg is None + assert json.loads(json.dumps(result.to_dict(), allow_nan=False))["phase_deg"] is None + + +def test_adjacent_representable_radii_do_not_cancel_the_log_ratio() -> None: + inner_radius = 1.0e-3 + outer_radius = float(np.nextafter(inner_radius, math.inf)) + parameters = coax_line_parameters(CoaxialCable(inner_radius, outer_radius), 1.0e9) + + expected_log_ratio = math.log1p((outer_radius - inner_radius) / inner_radius) + assert parameters.inductance_h_per_m == pytest.approx( + VACUUM_PERMEABILITY_H_PER_M * expected_log_ratio / math.tau, + rel=2.0e-15, + ) + assert math.isfinite(parameters.capacitance_f_per_m) + + +def test_large_finite_metrics_are_stable_and_unrepresentable_errors_fail() -> None: + metrics = complex_s_error_metrics([1.0e200 + 0.0j], [0.0j]) + assert metrics.complex_mae == pytest.approx(1.0e200) + assert metrics.complex_rmse == pytest.approx(1.0e200) + assert metrics.max_abs_complex_error == pytest.approx(1.0e200) + assert metrics.normalized_complex_rmse is None + + with pytest.raises(ValueError, match="finite float range"): + complex_s_error_metrics([1.0e308 + 0.0j], [-1.0e308 + 0.0j]) + + +def test_large_finite_load_and_reference_do_not_overflow_fractional_transforms() -> None: + largest_float = np.finfo(np.float64).max + network = ABCDMatrix.from_complex(2.0, 3.0, 4.0, 5.0) + input_impedance = terminated_input_impedance( + network, Termination.impedance(largest_float) + ) + matched_result = input_reflection( + ABCDMatrix.identity(), + Termination.impedance(largest_float), + largest_float, + ) + + assert input_impedance == pytest.approx(0.5) + assert matched_result.gamma == 0.0j + assert matched_result.phase_deg is None + + +@pytest.mark.parametrize( + ("kwargs", "error"), + [ + ({"inner_radius_m": 0.0, "outer_radius_m": 2.0e-3}, ValueError), + ({"inner_radius_m": 2.0e-3, "outer_radius_m": 2.0e-3}, ValueError), + ( + { + "inner_radius_m": 1.0e-3, + "outer_radius_m": 2.0e-3, + "relative_permittivity": float("inf"), + }, + ValueError, + ), + ( + { + "inner_radius_m": 1.0e-3, + "outer_radius_m": 2.0e-3, + "loss_tangent": -0.01, + }, + ValueError, + ), + ], +) +def test_invalid_cable_inputs_are_rejected(kwargs: dict[str, float], error: type[Exception]) -> None: + with pytest.raises(error): + CoaxialCable(**kwargs) + + +def test_invalid_network_metric_and_termination_inputs_are_rejected() -> None: + parameters = coax_line_parameters(_lossless_cable(), 1.0e9) + with pytest.raises(ValueError, match="frequency_hz"): + coax_line_parameters(_lossless_cable(), 0.0) + with pytest.raises(ValueError, match="length_m"): + uniform_line_abcd(parameters, -0.1) + with pytest.raises(ValueError, match="non-finite ABCD"): + uniform_line_abcd(parameters, np.finfo(np.float64).max) + with pytest.raises(ValueError, match="at least one"): + cascade_abcd() + with pytest.raises(ValueError, match="passive"): + Termination.impedance(-1.0) + with pytest.raises(TypeError, match="string"): + Termination(kind=1) # type: ignore[arg-type] + with pytest.raises(ValueError, match="finite"): + ABCDMatrix.from_complex(complex(float("nan"), 0.0), 0.0, 0.0, 1.0) + with pytest.raises(ValueError, match="shapes differ"): + complex_s_error_metrics([1.0j], [1.0j, 2.0j]) + with pytest.raises(ValueError, match="finite"): + complex_s_error_metrics([complex(float("nan"), 0.0)], [1.0j]) + with pytest.raises(ValueError, match="must not be empty"): + complex_s_error_metrics([], []) + with pytest.raises(TypeError, match="real scalar"): + complex_s_error_metrics([1.0j], [1.0j], phase_magnitude_floor=True) + with pytest.raises(ValueError, match="undefined"): + circular_phase_error_deg(0.0j, 1.0j) + + +def test_complex_value_rejects_nonfinite_parts() -> None: + with pytest.raises(ValueError, match="finite"): + ComplexValue(real=1.0, imag=float("inf")) + with pytest.raises(TypeError, match="real scalar"): + ComplexValue(real=True, imag=0.0) + + +def test_out_of_range_numeric_scalars_raise_domain_errors() -> None: + with pytest.raises(ValueError, match="finite"): + CoaxialCable(inner_radius_m=10**400, outer_radius_m=2 * 10**400) + with pytest.raises(ValueError, match="finite"): + ComplexValue.from_complex(10**400) diff --git a/tests/test_fem_config.py b/tests/test_fem_config.py index 48bf38d..1c32525 100644 --- a/tests/test_fem_config.py +++ b/tests/test_fem_config.py @@ -36,8 +36,108 @@ def test_iterative_preset_cannot_fall_back_to_global_lu() -> None: assert config.is_iterative assert config.pc_type == "asm" assert config.factor_solver_type is None + assert config.preconditioning_side == "right" with pytest.raises(ValueError, match="forbids LU"): LinearSolverConfig(solver_path="iterative", ksp_type="gmres", pc_type="lu") + with pytest.raises(ValueError, match="preconditioning_side"): + LinearSolverConfig(preconditioning_side="diagonal") + + +def test_absorption_shift_is_finite_nonnegative_and_iterative_only() -> None: + shifted = LinearSolverConfig.iterative_maxwell( + preconditioner_absorption_shift=0.5 + ) + assert shifted.preconditioner_absorption_shift == 0.5 + assert shifted.canonical()["preconditioner_absorption_shift"] == 0.5 + for invalid in (-1.0, float("inf"), float("nan")): + with pytest.raises(ValueError, match="finite and nonnegative"): + LinearSolverConfig.iterative_maxwell( + preconditioner_absorption_shift=invalid + ) + with pytest.raises(ValueError, match="only for iterative"): + LinearSolverConfig( + solver_path="direct", + preconditioner_absorption_shift=0.25, + ) + + +@pytest.mark.parametrize( + "reserved", + ( + "pc_type", + "-PC_TYPE", + "-- pc_type", + "ksp_type", + "ksp_pc_side", + "ksp_rtol", + "ksp_atol", + "ksp_max_it", + "pc_factor_mat_solver_type", + ), +) +def test_petsc_options_cannot_override_typed_top_level_fields( + reserved: str, +) -> None: + with pytest.raises(ValueError, match="typed top-level"): + LinearSolverConfig.iterative_maxwell(petsc_options={reserved: "lu"}) + + +def test_petsc_option_keys_are_normalized_without_blocking_nested_tuning() -> None: + config = LinearSolverConfig.iterative_maxwell( + petsc_options={ + "-SUB_PC_TYPE": "lu", + "sub_pc_factor_mat_solver_type": "mumps", + "KSP_GMRES_RESTART": 60, + "pc_asm_overlap": 2, + } + ) + assert dict(config.petsc_options) == { + "sub_pc_type": "lu", + "sub_pc_factor_mat_solver_type": "mumps", + "ksp_gmres_restart": 60, + "pc_asm_overlap": 2, + } + with pytest.raises(ValueError, match="duplicate normalized"): + LinearSolverConfig.iterative_maxwell( + petsc_options={"sub_pc_type": "ilu", "-SUB_PC_TYPE": "lu"} + ) + + +def test_p_multigrid_preset_is_typed_and_reserves_structure() -> None: + config = LinearSolverConfig.iterative_p_multigrid( + coarse_degree=1, + preconditioner_absorption_shift=0.5, + ) + assert config.is_iterative + assert config.uses_p_multigrid + assert config.pc_type == "mg" + assert config.p_multigrid_coarse_degree == 1 + assert config.preconditioning_side == "right" + assert config.petsc_options["mg_levels_1_ksp_type"] == "richardson" + assert config.petsc_options["mg_levels_1_ksp_max_it"] == 1 + assert config.petsc_options["mg_levels_1_pc_type"] == "asm" + assert config.petsc_options["mg_coarse_pc_type"] == "lu" + merged = LinearSolverConfig.iterative_p_multigrid( + petsc_options={"ksp_gmres_restart": 40} + ) + assert merged.petsc_options["ksp_gmres_restart"] == 40 + assert merged.petsc_options["mg_levels_1_pc_type"] == "asm" + assert merged.petsc_options["mg_coarse_pc_type"] == "lu" + assert config.canonical()["p_multigrid_coarse_degree"] == 1 + for reserved in ("pc_mg_levels", "pc_mg_galerkin", "pc_mg_type"): + with pytest.raises(ValueError, match="typed top-level"): + LinearSolverConfig.iterative_p_multigrid( + petsc_options={reserved: "invalid"} + ) + + +def test_p_multigrid_requires_iterative_mg_and_valid_coarse_degree() -> None: + with pytest.raises(ValueError, match="requires p_multigrid_coarse_degree"): + LinearSolverConfig(solver_path="iterative", pc_type="mg") + with pytest.raises(ValueError, match="requires iterative"): + LinearSolverConfig(p_multigrid_coarse_degree=1) + with pytest.raises(ValueError, match="must be 1 or 2"): + LinearSolverConfig.iterative_p_multigrid(coarse_degree=3) def test_reference_and_dut_are_distinct_model_states() -> None: diff --git a/tests/test_fem_forms.py b/tests/test_fem_forms.py new file mode 100644 index 0000000..e4ca9c8 --- /dev/null +++ b/tests/test_fem_forms.py @@ -0,0 +1,289 @@ +from __future__ import annotations + +import numpy as np +import pytest + + +def _tagged_cube(comm): + from dolfinx import mesh + + domain = mesh.create_unit_cube(comm, 1, 1, 1) + tdim = domain.topology.dim + fdim = tdim - 1 + local_cells = domain.topology.index_map(tdim).size_local + cell_indices = np.arange(local_cells, dtype=np.int32) + cell_tags = mesh.meshtags( + domain, + tdim, + cell_indices, + np.ones(local_cells, dtype=np.int32), + ) + left = mesh.locate_entities_boundary(domain, fdim, lambda x: np.isclose(x[0], 0.0)) + facet_tags = mesh.meshtags( + domain, + fdim, + np.asarray(left, dtype=np.int32), + np.full(left.size, 10, dtype=np.int32), + ) + return domain, cell_tags, facet_tags + + +def _contracts(): + from scatter3d.fem.tags import ( + BoundaryTagContract, + MeshTagContract, + VolumeTagContract, + ) + + volume = VolumeTagContract({"domain": 1}) + matched = MeshTagContract(volume, BoundaryTagContract(ports={"tx": 10})) + natural = MeshTagContract(volume, BoundaryTagContract(observation_tags=(10,))) + return matched, natural + + +def _port(*, tag: int = 10, impedance: complex = 200.0): + from scatter3d.fem.ports import PortDefinition + + return PortDefinition( + "tx", + tag, + field_wave_impedance_ohm=impedance, + outgoing_propagation_index=1.0, + circuit_reference_impedance_ohm=50.0, + target_forward_power_w=1.0, + ) + + +def _material_and_config(): + from scatter3d.fem.config import Material, MaterialMap, MaxwellProblemConfig + + return MaterialMap(Material(1.0, name="vacuum")), MaxwellProblemConfig(1) + + +@pytest.mark.heavy +def test_declared_ports_fail_closed_without_exact_matched_definitions() -> None: + pytest.importorskip("dolfinx") + from mpi4py import MPI + + from scatter3d.fem.forms import build_maxwell_forms + + domain, cell_tags, facet_tags = _tagged_cube(MPI.COMM_SELF) + matched, _ = _contracts() + materials, config = _material_and_config() + + with pytest.raises(ValueError, match="missing matched definitions"): + build_maxwell_forms( + domain, + cell_tags, + facet_tags, + matched, + materials, + config, + ) + with pytest.raises(ValueError, match="name/tag mismatches"): + build_maxwell_forms( + domain, + cell_tags, + facet_tags, + matched, + materials, + config, + matched_ports=(_port(tag=11),), + ) + + +@pytest.mark.heavy +def test_missing_matched_facet_tag_is_rejected_before_compilation() -> None: + pytest.importorskip("dolfinx") + from mpi4py import MPI + + from scatter3d.fem.forms import build_maxwell_forms + from scatter3d.fem.tags import ( + BoundaryTagContract, + MeshTagContract, + VolumeTagContract, + ) + + domain, cell_tags, facet_tags = _tagged_cube(MPI.COMM_SELF) + materials, config = _material_and_config() + contract = MeshTagContract( + VolumeTagContract({"domain": 1}), + BoundaryTagContract(ports={"tx": 11}), + ) + + with pytest.raises(ValueError, match="absent from the mesh"): + build_maxwell_forms( + domain, + cell_tags, + facet_tags, + contract, + materials, + config, + matched_ports=(_port(tag=11),), + ) + + +def _dense_matrix(form, boundary_conditions): + from dolfinx.fem import petsc as fem_petsc + + matrix = fem_petsc.assemble_matrix(form, bcs=boundary_conditions) + matrix.assemble() + dense = matrix.convert("dense") + values = dense.getDenseArray().copy() + dense.destroy() + matrix.destroy() + return values + + +@pytest.mark.heavy +def test_matched_operator_is_live_in_frequency_with_negative_i_sign() -> None: + pytest.importorskip("dolfinx") + import ufl + from dolfinx import fem + from mpi4py import MPI + + from scatter3d.fem.forms import build_maxwell_forms + from scatter3d.fem.ports import VACUUM_IMPEDANCE_OHM + + domain, cell_tags, facet_tags = _tagged_cube(MPI.COMM_SELF) + matched_contract, natural_contract = _contracts() + materials, config = _material_and_config() + port = _port(impedance=VACUUM_IMPEDANCE_OHM) + matched = build_maxwell_forms( + domain, + cell_tags, + facet_tags, + matched_contract, + materials, + config, + matched_ports=(port,), + initial_frequency_hz=1.0e8, + ) + natural = build_maxwell_forms( + domain, + cell_tags, + facet_tags, + natural_contract, + materials, + config, + initial_frequency_hz=1.0e8, + ) + + def boundary_matrix(frequency_hz: float) -> np.ndarray: + matched.update_frequency(frequency_hz) + natural.update_frequency(frequency_hz) + return _dense_matrix(matched.bilinear_form, []) - _dense_matrix( + natural.bilinear_form, [] + ) + + first = boundary_matrix(1.0e8) + second = boundary_matrix(2.0e8) + assert np.linalg.norm(first) > 0.0 + assert second == pytest.approx(2.0 * first, rel=2.0e-11, abs=2.0e-11) + + trial = ufl.TrialFunction(matched.function_space) + test = ufl.TestFunction(matched.function_space) + normal = ufl.FacetNormal(domain) + trial_t = ufl.cross(trial, normal) + test_t = ufl.cross(test, normal) + expected = fem.form( + (-1.0j * matched.k0) + * ufl.inner(trial_t, test_t) + * matched.ds(port.facet_tag) + ) + expected_values = _dense_matrix(expected, []) + assert second == pytest.approx(expected_values, rel=2.0e-11, abs=2.0e-11) + + +@pytest.mark.heavy +def test_incident_mode_rhs_has_minus_two_i_and_conjugate_test_convention() -> None: + pytest.importorskip("dolfinx") + import ufl + from dolfinx import fem + from dolfinx.fem import petsc as fem_petsc + from mpi4py import MPI + from petsc4py import PETSc + + from scatter3d.fem.forms import build_maxwell_forms + from scatter3d.fem.ports import ( + VACUUM_IMPEDANCE_OHM, + MatchedTEMPortExcitation, + normalize_port_mode, + ) + + domain, cell_tags, facet_tags = _tagged_cube(MPI.COMM_SELF) + matched_contract, _ = _contracts() + materials, config = _material_and_config() + port = _port(impedance=VACUUM_IMPEDANCE_OHM) + forms = build_maxwell_forms( + domain, + cell_tags, + facet_tags, + matched_contract, + materials, + config, + matched_ports=(port,), + initial_frequency_hz=1.0e8, + ) + raw_mode = fem.Function(forms.function_space) + raw_mode.interpolate( + lambda x: np.vstack( + ( + np.zeros(x.shape[1], dtype=PETSc.ScalarType), + np.ones(x.shape[1], dtype=PETSc.ScalarType), + np.zeros(x.shape[1], dtype=PETSc.ScalarType), + ) + ) + ) + mode = normalize_port_mode(raw_mode, facet_tags, port) + excitation = MatchedTEMPortExcitation(mode, amplitude=0.25 + 0.5j) + + actual = fem_petsc.assemble_vector(forms.matched_port_rhs_form(excitation)) + actual.ghostUpdate(addv=PETSc.InsertMode.ADD, mode=PETSc.ScatterMode.REVERSE) + actual_values = actual.getArray(readonly=True).copy() + + normal = ufl.FacetNormal(domain) + mode_t = ufl.cross(mode.field, normal) + test_t = ufl.cross(forms.test_function, normal) + expected_form = fem.form( + (-2.0j * forms.k0 * excitation.amplitude) + * ufl.inner(mode_t, test_t) + * forms.ds(port.facet_tag) + ) + expected = fem_petsc.assemble_vector(expected_form) + expected.ghostUpdate(addv=PETSc.InsertMode.ADD, mode=PETSc.ScatterMode.REVERSE) + expected_values = expected.getArray(readonly=True).copy() + + assert np.linalg.norm(actual_values) > 0.0 + assert actual_values == pytest.approx(expected_values, rel=2.0e-12, abs=2.0e-12) + actual.destroy() + expected.destroy() + + +@pytest.mark.heavy +def test_uncalibrated_surface_current_is_forbidden_on_matched_port_tag() -> None: + pytest.importorskip("dolfinx") + from dolfinx import fem + from mpi4py import MPI + + from scatter3d.fem.forms import build_maxwell_forms + from scatter3d.fem.ports import UncalibratedSurfaceCurrentExcitation + + domain, cell_tags, facet_tags = _tagged_cube(MPI.COMM_SELF) + matched_contract, _ = _contracts() + materials, config = _material_and_config() + port = _port() + forms = build_maxwell_forms( + domain, + cell_tags, + facet_tags, + matched_contract, + materials, + config, + matched_ports=(port,), + ) + current = fem.Function(forms.function_space) + load = UncalibratedSurfaceCurrentExcitation("not-a-port", 10, current) + + with pytest.raises(ValueError, match="never on matched-port"): + forms.uncalibrated_surface_current_rhs_form(load) diff --git a/tests/test_fem_pml.py b/tests/test_fem_pml.py index 5a917a5..6ca82d7 100644 --- a/tests/test_fem_pml.py +++ b/tests/test_fem_pml.py @@ -23,10 +23,10 @@ def _contains_identity(root, target) -> bool: def test_pml_tensor_retains_live_frequency_constant() -> None: pytest.importorskip("dolfinx") pytest.importorskip("ufl") + import ufl from dolfinx import fem, mesh from mpi4py import MPI from petsc4py import PETSc - import ufl from scatter3d.fem.config import PMLConfig from scatter3d.fem.pml import cartesian_pml_tensor diff --git a/tests/test_fem_ports.py b/tests/test_fem_ports.py new file mode 100644 index 0000000..d0a16f1 --- /dev/null +++ b/tests/test_fem_ports.py @@ -0,0 +1,255 @@ +from __future__ import annotations + +import json +import math + +import pytest + +from scatter3d.fem.ports import ( + VACUUM_IMPEDANCE_OHM, + MatchedTEMPortExcitation, + NormalizedPortMode, + PortDefinition, + UncalibratedSurfaceCurrentExcitation, + matched_tem_incident_coefficient, + matched_tem_operator_coefficient, + validate_port_definitions, +) + + +def _port(**overrides: object) -> PortDefinition: + values: dict[str, object] = { + "name": "tx", + "facet_tag": 10, + "field_wave_impedance_ohm": 240.0 + 30.0j, + "outgoing_propagation_index": 1.4 + 0.02j, + "circuit_reference_impedance_ohm": 50.0, + "target_forward_power_w": 1.0, + } + values.update(overrides) + return PortDefinition(**values) # type: ignore[arg-type] + + +def test_field_wave_impedance_is_required_and_keyword_only() -> None: + with pytest.raises(TypeError, match="field_wave_impedance_ohm"): + PortDefinition("tx", 10) # type: ignore[call-arg] + with pytest.raises(TypeError): + PortDefinition("tx", 10, 377.0) # type: ignore[misc] + + +def test_circuit_reference_never_changes_field_boundary_coefficients() -> None: + first = _port(circuit_reference_impedance_ohm=50.0) + second = _port(circuit_reference_impedance_ohm=75.0) + different_field = _port(field_wave_impedance_ohm=480.0 + 60.0j) + + assert matched_tem_operator_coefficient(17.0, first) == pytest.approx( + matched_tem_operator_coefficient(17.0, second) + ) + assert matched_tem_operator_coefficient(17.0, different_field) == pytest.approx( + 0.5 * matched_tem_operator_coefficient(17.0, first) + ) + + +@pytest.mark.parametrize( + "impedance", + [ + 0.0, + 50.0j, + -50.0, + -50.0 + 20.0j, + complex(math.inf, 0.0), + complex(math.nan, 0.0), + complex(1.0e-320, 0.0), + ], +) +def test_evanescent_nonpassive_zero_and_nonfinite_modes_are_rejected( + impedance: complex, +) -> None: + with pytest.raises(ValueError): + _port(field_wave_impedance_ohm=impedance) + + +def test_passive_lossy_mode_uses_real_admittance_for_forward_power() -> None: + port = _port(field_wave_impedance_ohm=40.0 + 30.0j) + + assert port.field_wave_admittance_siemens == pytest.approx(0.016 - 0.012j) + assert port.forward_power_admittance_siemens == pytest.approx(0.016) + assert port.forward_power_admittance_siemens != pytest.approx( + 1.0 / port.circuit_reference_impedance_ohm + ) + + +@pytest.mark.parametrize( + "propagation_index", + [0.0, 1.0j, -1.0 + 0.0j, 1.0 - 0.01j, complex(math.inf, 0.0)], +) +def test_evanescent_backward_active_and_nonfinite_propagation_is_rejected( + propagation_index: complex, +) -> None: + with pytest.raises(ValueError, match="outgoing_propagation_index"): + _port(outgoing_propagation_index=propagation_index) + + +def test_operator_and_incident_rhs_have_exp_minus_iwt_sign_and_factor_two() -> None: + port = _port(field_wave_impedance_ohm=VACUUM_IMPEDANCE_OHM) + mode = NormalizedPortMode(port, object(), raw_forward_power_w=0.25, scale=2.0) + excitation = MatchedTEMPortExcitation(mode, amplitude=0.5 - 0.25j) + k0 = 9.0 + + operator = matched_tem_operator_coefficient(k0, port) + incident = matched_tem_incident_coefficient(k0, excitation) + + assert operator == pytest.approx(-9.0j) + assert incident == pytest.approx(-18.0j * excitation.amplitude) + assert operator.real == pytest.approx(0.0, abs=1.0e-15) + assert operator.imag < 0.0 + + +def test_incident_amplitude_has_explicit_power_meaning() -> None: + port = _port(target_forward_power_w=2.5) + mode = NormalizedPortMode(port, object(), raw_forward_power_w=0.4, scale=2.5) + excitation = MatchedTEMPortExcitation(mode, amplitude=1.0 + 2.0j) + + assert excitation.definition is port + assert excitation.incident_power_w == pytest.approx(12.5) + with pytest.raises(ValueError, match="nonzero"): + MatchedTEMPortExcitation(mode, amplitude=0.0j) + with pytest.raises(ValueError, match="nonfinite incident power"): + MatchedTEMPortExcitation(mode, amplitude=1.0e308 + 0.0j) + + +def test_port_canonical_record_separates_impedances_and_is_strict_json() -> None: + port = _port( + field_wave_impedance_ohm=240.0 + 30.0j, + circuit_reference_impedance_ohm=50.0, + ) + payload = port.canonical() + + assert payload["field_wave_impedance_ohm"] == [240.0, 30.0] + assert payload["outgoing_propagation_index"] == [1.4, 0.02] + assert payload["circuit_reference_impedance_ohm"] == 50.0 + assert payload["mode_model"] == "matched-single-tem" + assert "reference_impedance_ohm" not in payload + json.dumps(payload, allow_nan=False) + + +def test_mesh_contract_and_physical_definitions_must_match_one_to_one() -> None: + tx = _port(name="tx", facet_tag=10) + rx = _port(name="rx", facet_tag=11) + + assert validate_port_definitions((tx, rx), {"tx": 10, "rx": 11}) == (tx, rx) + with pytest.raises(ValueError, match="missing matched definitions"): + validate_port_definitions((tx,), {"tx": 10, "rx": 11}) + with pytest.raises(ValueError, match="undeclared ports"): + validate_port_definitions((tx, rx), {"tx": 10}) + with pytest.raises(ValueError, match="name/tag mismatches"): + validate_port_definitions((tx,), {"tx": 99}) + with pytest.raises(ValueError, match="names are duplicated"): + validate_port_definitions((tx, _port(name="tx", facet_tag=12)), {"tx": 10}) + with pytest.raises(ValueError, match="facet tags are duplicated"): + validate_port_definitions((tx, _port(name="rx", facet_tag=10)), {"tx": 10}) + + +def test_generic_surface_current_is_explicitly_uncalibrated() -> None: + sentinel = object() + load = UncalibratedSurfaceCurrentExcitation( + boundary_name="diagnostic-load", + facet_tag=90, + surface_current=sentinel, + amplitude=2.0j, + ) + + assert load.surface_current is sentinel + assert load.boundary_name == "diagnostic-load" + assert not hasattr(load, "definition") + assert not hasattr(load, "field_wave_impedance_ohm") + + +def test_names_tags_powers_and_amplitudes_fail_closed() -> None: + with pytest.raises(ValueError, match="name"): + _port(name=" ") + with pytest.raises((TypeError, ValueError), match="facet_tag"): + _port(facet_tag=2.5) + with pytest.raises(ValueError, match="target_forward_power_w"): + _port(target_forward_power_w=0.0) + with pytest.raises(ValueError, match="circuit_reference_impedance_ohm"): + _port(circuit_reference_impedance_ohm=-50.0) + with pytest.raises(ValueError, match="finite"): + UncalibratedSurfaceCurrentExcitation("load", 90, object(), complex(math.nan, 0)) + + +@pytest.mark.heavy +def test_mode_normalization_uses_real_poynting_power_not_circuit_impedance() -> None: + pytest.importorskip("dolfinx") + import numpy as np + from dolfinx import fem, mesh + from mpi4py import MPI + from petsc4py import PETSc + + from scatter3d.fem.ports import ( + normalize_port_mode, + total_electric_modal_coefficient, + ) + + domain = mesh.create_unit_cube(MPI.COMM_SELF, 1, 1, 1) + fdim = domain.topology.dim - 1 + left = mesh.locate_entities_boundary(domain, fdim, lambda x: np.isclose(x[0], 0.0)) + tags = mesh.meshtags( + domain, + fdim, + np.asarray(left, dtype=np.int32), + np.full(left.size, 10, dtype=np.int32), + ) + space = fem.functionspace(domain, ("N1curl", 1)) + raw_mode = fem.Function(space) + raw_mode.interpolate( + lambda x: np.vstack( + ( + np.zeros(x.shape[1], dtype=PETSc.ScalarType), + np.full(x.shape[1], 2.0, dtype=PETSc.ScalarType), + np.zeros(x.shape[1], dtype=PETSc.ScalarType), + ) + ) + ) + port = _port( + field_wave_impedance_ohm=200.0, + circuit_reference_impedance_ohm=50.0, + target_forward_power_w=1.0, + ) + + normalized = normalize_port_mode(raw_mode, tags, port) + + # Unit-area face, |E_t|=2: P = 1/2 * (1/200) * 4 = 0.01 W. + assert normalized.raw_forward_power_w == pytest.approx(0.01, rel=1.0e-11) + assert normalized.scale == pytest.approx(10.0, rel=1.0e-11) + expected_coefficient = 0.3 - 0.7j + solution = fem.Function(space) + solution.x.array[:] = expected_coefficient * normalized.field.x.array + solution.x.scatter_forward() + assert total_electric_modal_coefficient( + solution, normalized, tags + ) == pytest.approx(expected_coefficient, rel=1.0e-11, abs=1.0e-11) + + +@pytest.mark.heavy +def test_mode_normalization_rejects_absent_port_tag() -> None: + pytest.importorskip("dolfinx") + import numpy as np + from dolfinx import fem, mesh + from mpi4py import MPI + + from scatter3d.fem.ports import normalize_port_mode + + domain = mesh.create_unit_cube(MPI.COMM_SELF, 1, 1, 1) + fdim = domain.topology.dim - 1 + left = mesh.locate_entities_boundary(domain, fdim, lambda x: np.isclose(x[0], 0.0)) + tags = mesh.meshtags( + domain, + fdim, + np.asarray(left, dtype=np.int32), + np.full(left.size, 10, dtype=np.int32), + ) + mode = fem.Function(fem.functionspace(domain, ("N1curl", 1))) + + with pytest.raises(ValueError, match="absent"): + normalize_port_mode(mode, tags, _port(facet_tag=11)) diff --git a/tests/test_fem_solver.py b/tests/test_fem_solver.py index 5486146..766a0fc 100644 --- a/tests/test_fem_solver.py +++ b/tests/test_fem_solver.py @@ -1,9 +1,188 @@ from __future__ import annotations +import json +import os +import stat +import subprocess +import sys +from pathlib import Path + import numpy as np import pytest +@pytest.mark.heavy +def test_wrong_expected_dof_fails_before_any_rhs_solve(tmp_path: Path) -> None: + pytest.importorskip("dolfinx") + repository = Path(__file__).resolve().parents[1] + output = tmp_path / "wrong-dof-preflight.json" + command = [ + sys.executable, + "validation/fem_smoke.py", + "--solver", + "iterative", + "--iterative-hierarchy", + "p-multigrid", + "--p-multigrid-coarse-degree", + "1", + "--iterative-local-pc", + "lu", + "--degree", + "3", + "--subdivisions", + "2", + "--frequencies-hz", + "1.0e8", + "--expected-global-dofs", + "1", + "--output", + str(output), + ] + completed = subprocess.run( + command, + cwd=repository, + check=False, + capture_output=True, + text=True, + timeout=120, + ) + assert completed.returncode == 1, completed.stderr + payload = json.loads(output.read_text(encoding="utf-8")) + assert payload["status"] == "FAILED" + assert payload["passed"] is False + assert payload["execution_phase"] == "preflight" + assert payload["gates"]["expected_global_dofs"]["status"] == "FAILED" + assert payload["gates"]["convergence_and_true_residual"]["status"] == "NOT RUN" + assert "rhs_solves" not in payload + if os.name == "posix": + assert stat.S_IMODE(output.stat().st_mode) == 0o644 + + +def test_petsc_asm_view_parser_requires_effective_type_and_overlap() -> None: + from scatter3d.fem.diagnostics import parse_petsc_asm_view + + view = """ + PC Object: 1 MPI process + type: asm + total subdomain blocks = 1, amount of overlap = 2 + restriction/interpolation type - RESTRICT + """ + assert parse_petsc_asm_view(view) == ("restrict", 2) + with pytest.raises(ValueError, match="amount of overlap"): + parse_petsc_asm_view("restriction/interpolation type - BASIC") + with pytest.raises(ValueError, match="restriction/interpolation type"): + parse_petsc_asm_view("amount of overlap = 1") + + +def test_petsc_mg_view_parser_requires_non_galerkin_hierarchy_fields() -> None: + from scatter3d.fem.diagnostics import parse_petsc_mg_view + + view = """ + type is MULTIPLICATIVE, levels=2 cycles=v + Not using Galerkin computed coarse grid matrices + """ + assert parse_petsc_mg_view(view) == ("multiplicative", 2, "v", "none") + with pytest.raises(ValueError, match="type/levels/cycles"): + parse_petsc_mg_view("Not using Galerkin computed coarse grid matrices") + with pytest.raises(ValueError, match="Galerkin mode"): + parse_petsc_mg_view("type is MULTIPLICATIVE, levels=2 cycles=v") + + +def test_collective_view_path_broadcasts_rank_zero_creation_failure( + monkeypatch, +) -> None: + import scatter3d.fem.diagnostics as diagnostics + + class RootComm: + rank = 0 + + def __init__(self) -> None: + self.envelopes = [] + + def bcast(self, value, root=0): + assert root == 0 + self.envelopes.append(value) + return value + + def fail_mkstemp(*args, **kwargs): + del args, kwargs + raise OSError("injected mkstemp failure") + + comm = RootComm() + monkeypatch.setattr(diagnostics.tempfile, "mkstemp", fail_mkstemp) + with pytest.raises(RuntimeError, match="injected mkstemp failure"): + diagnostics._collective_temporary_view_path(comm) + assert comm.envelopes == [(None, "OSError: injected mkstemp failure")] + + +def test_collective_view_path_peer_receives_rank_zero_failure() -> None: + from scatter3d.fem.diagnostics import _collective_temporary_view_path + + class PeerComm: + rank = 1 + + def bcast(self, value, root=0): + assert value is None + assert root == 0 + return None, "OSError: injected rank-zero failure" + + with pytest.raises(RuntimeError, match="injected rank-zero failure"): + _collective_temporary_view_path(PeerComm()) + + +def test_collective_view_cleanup_is_nonthrowing(monkeypatch) -> None: + import scatter3d.fem.diagnostics as diagnostics + + def fail_unlink(self): + del self + raise PermissionError("injected unlink failure") + + monkeypatch.setattr(diagnostics.Path, "unlink", fail_unlink) + diagnostics._remove_temporary_view("unused") + + +def test_effective_component_aggregation_deduplicates_all_mpi_ranks() -> None: + from scatter3d.fem.diagnostics import ( + SolverComponentDiagnostics, + _aggregate_solver_components, + ) + + common = ("preonly", "ilu", None, "scatter3d_0_sub_", 1, "none") + local_lu = ( + "preonly", + "lu", + "mumps", + "scatter3d_0_sub_", + 1, + "none", + ) + + class FakeComm: + def allgather(self, value): + del value + return ((common, common), (common, local_lu)) + + local = ( + SolverComponentDiagnostics( + path="local", + ksp_type=common[0], + pc_type=common[1], + factor_solver_type=common[2], + options_prefix=common[3], + maximum_iterations=common[4], + norm_type=common[5], + mpi_ranks=(), + instances=1, + ), + ) + aggregated = _aggregate_solver_components(FakeComm(), local, "asm.subdomains") + by_pc = {item.pc_type: item for item in aggregated} + assert by_pc["ilu"].mpi_ranks == (0, 1) + assert by_pc["ilu"].instances == 3 + assert by_pc["lu"].mpi_ranks == (1,) + assert by_pc["lu"].instances == 1 + + def _tiny_problem(comm): pytest.importorskip("dolfinx") from dolfinx import mesh @@ -39,7 +218,13 @@ def _tiny_problem(comm): return domain, cell_tags, facet_tags -def _solver_and_ports(comm, iterative: bool = False): +def _solver_and_ports( + comm, + iterative: bool = False, + absorption_shift: float = 0.0, + degree: int = 1, + p_multigrid: bool = False, +): from dolfinx import fem from petsc4py import PETSc @@ -49,7 +234,11 @@ def _solver_and_ports(comm, iterative: bool = False): MaterialMap, MaxwellProblemConfig, ) - from scatter3d.fem.ports import PortDefinition, PortExcitation + from scatter3d.fem.ports import ( + MatchedTEMPortExcitation, + PortDefinition, + normalize_port_mode, + ) from scatter3d.fem.solver import MaxwellSweepSolver from scatter3d.fem.tags import ( BoundaryTagContract, @@ -62,10 +251,26 @@ def _solver_and_ports(comm, iterative: bool = False): VolumeTagContract({"domain": 1}), BoundaryTagContract(ports={"left": 10, "right": 11}, pec_tags=(20,)), ) - solver_config = ( - LinearSolverConfig.iterative_maxwell(maximum_iterations=500) - if iterative - else LinearSolverConfig.direct() + if p_multigrid: + solver_config = LinearSolverConfig.iterative_p_multigrid( + maximum_iterations=500, + preconditioner_absorption_shift=absorption_shift, + ) + elif iterative: + solver_config = LinearSolverConfig.iterative_maxwell( + maximum_iterations=500, + preconditioner_absorption_shift=absorption_shift, + ) + else: + solver_config = LinearSolverConfig.direct() + definitions = tuple( + PortDefinition( + name, + tag, + field_wave_impedance_ohm=200.0, + outgoing_propagation_index=1.0, + ) + for name, tag in (("left", 10), ("right", 11)) ) solver = MaxwellSweepSolver.from_mesh( domain, @@ -73,14 +278,15 @@ def _solver_and_ports(comm, iterative: bool = False): facet_tags, contract, MaterialMap(Material(2.0, conductivity_s_per_m=0.02, name="lossy")), - MaxwellProblemConfig(polynomial_degree=1), + MaxwellProblemConfig(polynomial_degree=degree), + matched_ports=definitions, solver_config=solver_config, initial_frequency_hz=1.0e8, ) ports = [] - for name, tag in (("left", 10), ("right", 11)): - current = fem.Function(solver.function_space) - current.interpolate( + for definition in definitions: + raw_mode = fem.Function(solver.function_space) + raw_mode.interpolate( lambda x: np.vstack( ( np.zeros(x.shape[1], dtype=PETSc.ScalarType), @@ -89,8 +295,8 @@ def _solver_and_ports(comm, iterative: bool = False): ) ) ) - definition = PortDefinition(name, tag) - ports.append(PortExcitation(definition, current)) + mode = normalize_port_mode(raw_mode, facet_tags, definition) + ports.append(MatchedTEMPortExcitation(mode)) return solver, ports @@ -102,9 +308,21 @@ def test_direct_solver_assembles_and_factorizes_once_per_frequency() -> None: solver, ports = _solver_and_ports(MPI.COMM_SELF) result = solver.solve((1.0e8, 1.2e8), ports) assert result.matrix_assemblies == 2 + assert result.preconditioner_matrix_assemblies == 0 assert result.operator_setups == 2 + assert result.global_numeric_factorizations == 2 assert result.numeric_factorizations == 2 assert result.rhs_solves == 4 + assert all( + item.preconditioner_operator_is_physical + and item.preconditioner_absorption_shift == 0.0 + and item.solver_hierarchy.requested.pc_type == "lu" + and item.solver_hierarchy.effective.top_level.pc_type == "lu" + and item.solver_hierarchy.effective.top_level.factor_solver_type == "mumps" + and item.solver_hierarchy.effective.top_level.mpi_ranks == (0,) + and item.solver_hierarchy.effective.top_level.instances == 1 + for item in result.diagnostics + ) assert all( port.converged_reason > 0 and port.true_relative_residual < 1.0e-9 for frequency in result.diagnostics @@ -119,6 +337,7 @@ def test_degree_three_edge_space_builds() -> None: from scatter3d.fem.config import Material, MaterialMap, MaxwellProblemConfig from scatter3d.fem.forms import build_maxwell_forms + from scatter3d.fem.ports import PortDefinition from scatter3d.fem.tags import ( BoundaryTagContract, MeshTagContract, @@ -136,10 +355,409 @@ def test_degree_three_edge_space_builds() -> None: ), MaterialMap(Material(1.0)), MaxwellProblemConfig(polynomial_degree=3), + matched_ports=( + PortDefinition( + "left", + 10, + field_wave_impedance_ohm=377.0, + outgoing_propagation_index=1.0, + ), + PortDefinition( + "right", + 11, + field_wave_impedance_ohm=377.0, + outgoing_propagation_index=1.0, + ), + ), ) assert forms.function_space.dofmap.index_map.size_global > 0 +@pytest.mark.heavy +def test_nested_asm_options_remain_available_through_setup() -> None: + pytest.importorskip("dolfinx") + from mpi4py import MPI + from petsc4py import PETSc + + from scatter3d.fem.config import LinearSolverConfig + + solver, ports = _solver_and_ports(MPI.COMM_SELF, iterative=True) + solver.solver_config = LinearSolverConfig.iterative_maxwell( + maximum_iterations=1, + petsc_options={ + "sub_ksp_type": "preonly", + "sub_pc_type": "scatter3d_deliberately_invalid_pc", + }, + ) + with pytest.raises(PETSc.Error): + solver.solve((1.0e8,), ports, retain_solutions=False) + + +@pytest.mark.heavy +def test_external_prefixed_option_cannot_change_iterative_top_pc_to_lu() -> None: + pytest.importorskip("dolfinx") + from mpi4py import MPI + from petsc4py import PETSc + + solver, ports = _solver_and_ports(MPI.COMM_SELF, iterative=True) + key = "scatter3d_0_pc_type" + options = PETSc.Options() + options[key] = "lu" + try: + with pytest.raises(RuntimeError, match="global factorizing PC"): + solver.solve((1.0e8,), ports, retain_solutions=False) + finally: + del options[key] + + +@pytest.mark.heavy +def test_effective_asm_local_lu_uses_mumps() -> None: + pytest.importorskip("dolfinx") + from mpi4py import MPI + + from scatter3d.fem.config import LinearSolverConfig + + solver, ports = _solver_and_ports(MPI.COMM_SELF, iterative=True) + solver.solver_config = LinearSolverConfig.iterative_maxwell( + maximum_iterations=500, + petsc_options={ + "sub_ksp_type": "preonly", + "sub_pc_type": "lu", + "sub_pc_factor_mat_solver_type": "mumps", + }, + ) + result = solver.solve((1.0e8,), ports, retain_solutions=False) + subdomains = result.diagnostics[0].solver_hierarchy.effective.asm_subdomain_solvers + assert subdomains + assert all( + item.ksp_type == "preonly" + and item.pc_type == "lu" + and item.factor_solver_type == "mumps" + for item in subdomains + ) + + +@pytest.mark.heavy +def test_shifted_preconditioner_preserves_physical_operator() -> None: + pytest.importorskip("dolfinx") + from dolfinx.fem import petsc as fem_petsc + from mpi4py import MPI + + solver, _ = _solver_and_ports(MPI.COMM_SELF, iterative=True) + forms = solver.forms + matrices = [] + try: + forms.set_preconditioner_absorption_shift(0.0) + physical_zero = fem_petsc.assemble_matrix( + forms.bilinear_form, bcs=forms.boundary_conditions + ) + physical_zero.assemble() + matrices.append(physical_zero) + preconditioner_zero = fem_petsc.assemble_matrix( + forms.preconditioner_bilinear_form, + bcs=forms.boundary_conditions, + ) + preconditioner_zero.assemble() + matrices.append(preconditioner_zero) + + forms.set_preconditioner_absorption_shift(0.5) + physical_shifted = fem_petsc.assemble_matrix( + forms.bilinear_form, bcs=forms.boundary_conditions + ) + physical_shifted.assemble() + matrices.append(physical_shifted) + preconditioner_shifted = fem_petsc.assemble_matrix( + forms.preconditioner_bilinear_form, + bcs=forms.boundary_conditions, + ) + preconditioner_shifted.assemble() + matrices.append(preconditioner_shifted) + + assert physical_zero.equal(preconditioner_zero) + assert physical_zero.equal(physical_shifted) + assert not preconditioner_zero.equal(preconditioner_shifted) + finally: + for matrix in reversed(matrices): + matrix.destroy() + + +@pytest.mark.heavy +def test_shifted_iterative_path_reports_effective_asm_hierarchy() -> None: + pytest.importorskip("dolfinx") + from mpi4py import MPI + + solver, ports = _solver_and_ports( + MPI.COMM_SELF, iterative=True, absorption_shift=0.5 + ) + result = solver.solve((1.0e8,), ports, retain_solutions=False) + assert result.preconditioner_matrix_assemblies == 1 + diagnostics = result.diagnostics[0] + assert diagnostics.preconditioner_absorption_shift == 0.5 + assert not diagnostics.preconditioner_operator_is_physical + assert diagnostics.preconditioner_matrix_nonzeros > 0 + assert diagnostics.preconditioner_assembly_seconds >= 0.0 + hierarchy = diagnostics.solver_hierarchy + assert hierarchy.requested.pc_type == "asm" + assert hierarchy.requested.preconditioning_side == "right" + assert hierarchy.effective.top_level.ksp_type == "fgmres" + assert hierarchy.effective.top_level.pc_type == "asm" + assert hierarchy.effective.preconditioning_side == "right" + assert hierarchy.effective.asm_type == "restrict" + assert hierarchy.effective.asm_overlap == 1 + assert "amount of overlap = 1" in hierarchy.effective.petsc_view_ascii + assert ( + "restriction/interpolation type - RESTRICT" + in hierarchy.effective.petsc_view_ascii + ) + assert hierarchy.effective.asm_subdomain_solvers + assert all( + item.ksp_type == "preonly" + and item.pc_type == "ilu" + and item.mpi_ranks == (0,) + and item.instances >= 1 + for item in hierarchy.effective.asm_subdomain_solvers + ) + + +@pytest.mark.heavy +def test_two_level_p_multigrid_reuses_transfer_and_reports_live_hierarchy() -> None: + pytest.importorskip("dolfinx") + from mpi4py import MPI + + solver, ports = _solver_and_ports( + MPI.COMM_SELF, + absorption_shift=0.5, + degree=3, + p_multigrid=True, + ) + result = solver.solve((1.0e8, 1.2e8), ports, retain_solutions=False) + assert result.matrix_assemblies == 2 + assert result.preconditioner_matrix_assemblies == 2 + assert result.coarse_preconditioner_matrix_assemblies == 2 + assert result.transfer_operator_assemblies == 1 + assert result.operator_setups == 2 + assert result.global_numeric_factorizations == 0 + assert result.coarse_global_factorizations == 2 + assert result.rhs_solves == 4 + for item in result.diagnostics: + assert item.fine_degree == 3 + assert item.coarse_degree == 1 + assert item.coarse_global_complex_dofs < item.global_complex_dofs + assert item.p_multigrid_operator_checks_passed is True + transfer = item.transfer_operator + assert transfer is not None + assert transfer.direction == "coarse_to_fine" + assert transfer.rows == item.global_complex_dofs + assert transfer.columns == item.coarse_global_complex_dofs + assert transfer.nonzeros > 0 + assert transfer.constrained_fine_rows > 0 + assert transfer.constrained_coarse_columns > 0 + assert transfer.maximum_imaginary_abs == 0.0 + effective = item.solver_hierarchy.effective + assert effective.mg_levels == 2 + assert effective.mg_type == "multiplicative" + assert effective.mg_cycle_type == "v" + assert effective.mg_galerkin == "none" + assert effective.mg_fine_smoother is not None + assert effective.mg_fine_smoother.ksp_type == "richardson" + assert effective.mg_fine_smoother.pc_type == "asm" + assert effective.mg_fine_smoother.maximum_iterations == 1 + assert effective.mg_fine_smoother.norm_type == "none" + assert effective.mg_fine_asm_type == "restrict" + assert effective.mg_fine_asm_overlap == 1 + assert effective.mg_coarse_solver is not None + assert effective.mg_coarse_solver.pc_type == "lu" + assert effective.mg_coarse_solver.factor_solver_type == "mumps" + assert all( + component.pc_type == "lu" + and component.factor_solver_type == "mumps" + for component in effective.mg_fine_asm_subdomain_solvers + ) + + +@pytest.mark.heavy +def test_p_multigrid_shift_changes_both_p_levels_but_not_physical_a() -> None: + pytest.importorskip("dolfinx") + from dolfinx.fem import petsc as fem_petsc + from mpi4py import MPI + + solver, _ = _solver_and_ports( + MPI.COMM_SELF, degree=3, p_multigrid=True + ) + coarse = solver.coarse_forms + assert coarse is not None + matrices = [] + + def assemble(form, bcs): + matrix = fem_petsc.assemble_matrix(form, bcs=bcs) + matrix.assemble() + matrices.append(matrix) + return matrix + + try: + solver.forms.set_preconditioner_absorption_shift(0.0) + coarse.set_preconditioner_absorption_shift(0.0) + physical_zero = assemble( + solver.forms.bilinear_form, solver.forms.boundary_conditions + ) + fine_zero = assemble( + solver.forms.preconditioner_bilinear_form, + solver.forms.boundary_conditions, + ) + coarse_zero = assemble( + coarse.preconditioner_bilinear_form, coarse.boundary_conditions + ) + solver.forms.set_preconditioner_absorption_shift(0.5) + coarse.set_preconditioner_absorption_shift(0.5) + physical_shifted = assemble( + solver.forms.bilinear_form, solver.forms.boundary_conditions + ) + fine_shifted = assemble( + solver.forms.preconditioner_bilinear_form, + solver.forms.boundary_conditions, + ) + coarse_shifted = assemble( + coarse.preconditioner_bilinear_form, coarse.boundary_conditions + ) + assert physical_zero.equal(physical_shifted) + assert not fine_zero.equal(fine_shifted) + assert not coarse_zero.equal(coarse_shifted) + finally: + for matrix in reversed(matrices): + matrix.destroy() + + +@pytest.mark.heavy +def test_p_multigrid_synchronizes_material_changes_to_coarse_forms() -> None: + pytest.importorskip("dolfinx") + from dolfinx.fem import petsc as fem_petsc + from mpi4py import MPI + + from scatter3d.fem.config import Material, MaterialMap + + solver, ports = _solver_and_ports( + MPI.COMM_SELF, + absorption_shift=0.5, + degree=3, + p_multigrid=True, + ) + coarse = solver.coarse_forms + assert coarse is not None + matrices = [] + + def assemble(form, bcs): + matrix = fem_petsc.assemble_matrix(form, bcs=bcs) + matrix.assemble() + matrices.append(matrix) + return matrix + + try: + fine_before = assemble( + solver.forms.bilinear_form, solver.forms.boundary_conditions + ) + coarse_before = assemble( + coarse.preconditioner_bilinear_form, coarse.boundary_conditions + ) + replacement = MaterialMap( + Material(3.0, conductivity_s_per_m=0.05, name="replacement") + ) + solver.forms.set_materials(replacement) + result = solver.solve((1.0e8,), ports, retain_solutions=False) + assert result.diagnostics[0].p_multigrid_operator_checks_passed is True + assert coarse.materials is replacement + fine_after = assemble( + solver.forms.bilinear_form, solver.forms.boundary_conditions + ) + coarse_after = assemble( + coarse.preconditioner_bilinear_form, coarse.boundary_conditions + ) + assert not fine_before.equal(fine_after) + assert not coarse_before.equal(coarse_after) + finally: + for matrix in reversed(matrices): + matrix.destroy() + + +@pytest.mark.heavy +def test_p_multigrid_typed_structure_wins_over_prefixed_external_options() -> None: + pytest.importorskip("dolfinx") + from mpi4py import MPI + from petsc4py import PETSc + + solver, ports = _solver_and_ports( + MPI.COMM_SELF, + absorption_shift=0.5, + degree=3, + p_multigrid=True, + ) + options = PETSc.Options() + keys = ("scatter3d_0_pc_type", "scatter3d_0_pc_mg_levels") + options[keys[0]] = "asm" + options[keys[1]] = 3 + try: + result = solver.solve((1.0e8,), ports, retain_solutions=False) + finally: + for key in keys: + if options.hasName(key): + del options[key] + effective = result.diagnostics[0].solver_hierarchy.effective + assert effective.top_level.pc_type == "mg" + assert effective.mg_levels == 2 + + +@pytest.mark.heavy +def test_p_multigrid_invalid_coarse_pc_option_is_consumed() -> None: + pytest.importorskip("dolfinx") + from mpi4py import MPI + from petsc4py import PETSc + + from scatter3d.fem.config import LinearSolverConfig + + solver, ports = _solver_and_ports( + MPI.COMM_SELF, degree=3, p_multigrid=True + ) + solver.solver_config = LinearSolverConfig.iterative_p_multigrid( + maximum_iterations=1, + petsc_options={ + "mg_coarse_pc_type": "scatter3d_deliberately_invalid_pc", + }, + ) + with pytest.raises(PETSc.Error): + solver.solve((1.0e8,), ports, retain_solutions=False) + + +@pytest.mark.heavy +@pytest.mark.mpi +def test_p_multigrid_effective_hierarchy_is_aggregated_across_ranks() -> None: + pytest.importorskip("dolfinx") + from mpi4py import MPI + + if MPI.COMM_WORLD.size < 2: + pytest.skip("run under mpirun -n 2 or more") + solver, ports = _solver_and_ports( + MPI.COMM_WORLD, + absorption_shift=0.5, + degree=3, + p_multigrid=True, + ) + result = solver.solve((1.0e8,), ports, retain_solutions=False) + effective = result.diagnostics[0].solver_hierarchy.effective + assert effective.mg_fine_smoother is not None + assert effective.mg_fine_smoother.mpi_ranks == tuple( + range(MPI.COMM_WORLD.size) + ) + assert effective.mg_coarse_solver is not None + assert effective.mg_coarse_solver.mpi_ranks == tuple( + range(MPI.COMM_WORLD.size) + ) + covered_ranks = { + rank + for component in effective.mg_fine_asm_subdomain_solvers + for rank in component.mpi_ranks + } + assert covered_ranks == set(range(MPI.COMM_WORLD.size)) + + @pytest.mark.heavy @pytest.mark.mpi def test_iterative_path_is_distributed_and_never_counts_a_factorization() -> None: @@ -151,5 +769,14 @@ def test_iterative_path_is_distributed_and_never_counts_a_factorization() -> Non solver, ports = _solver_and_ports(MPI.COMM_WORLD, iterative=True) result = solver.solve((1.0e8,), ports, retain_solutions=False) assert result.solver_path == "iterative" + assert result.global_numeric_factorizations == 0 assert result.numeric_factorizations == 0 assert result.diagnostics[0].global_complex_dofs > 0 + hierarchy = result.diagnostics[0].solver_hierarchy.effective + assert hierarchy.top_level.mpi_ranks == tuple(range(MPI.COMM_WORLD.size)) + covered_ranks = { + rank + for component in hierarchy.asm_subdomain_solvers + for rank in component.mpi_ranks + } + assert covered_ranks == set(range(MPI.COMM_WORLD.size)) diff --git a/tests/test_inverse.py b/tests/test_inverse.py index f9b2cce..87c2723 100644 --- a/tests/test_inverse.py +++ b/tests/test_inverse.py @@ -9,6 +9,22 @@ tsvd_solve, whiten_system, ) +from scatter3d.measurement import ScatteringDataset + + +def _single_observation_dataset( + value: complex, + *, + frequency_hz: float = 5.0e9, + angle_deg: float = 0.0, + port_label: str = "P1", +) -> ScatteringDataset: + return ScatteringDataset( + np.asarray([[[[value]]]], dtype=np.complex128), + frequencies_hz=np.asarray([frequency_hz], dtype=np.float64), + angles_deg=np.asarray([angle_deg], dtype=np.float64), + port_labels=(port_label,), + ) def test_paired_repeat_covariance_uses_complex_x_xh_orientation() -> None: @@ -18,26 +34,32 @@ def test_paired_repeat_covariance_uses_complex_x_xh_orientation() -> None: [1 + 2j, 2 - 1j, -1 + 0.5j, 0.25 - 2j], [2 + 0j, -1 + 3j, 0.5 + 1j, 2 + 0.5j], [-1 + 1j, 0.5 - 2j, 3 - 0.25j, -0.5 + 1j], + [0.5 - 0.5j, 1 + 1.5j, -2 + 0.25j, 1.5 - 0.75j], + [3 - 1j, -0.25 + 0.5j, 1.25 - 1.5j, -1 + 2j], ], dtype=np.complex128, ) - reference = np.zeros((3, 1, 1, 2, 2), dtype=np.complex128) - dut = samples.reshape(3, 1, 1, 2, 2) + reference = np.zeros((5, 1, 1, 2, 2), dtype=np.complex128) + dut = samples.reshape(5, 1, 1, 2, 2) estimate = estimate_repeat_differential_noise( reference, dut, full_covariance=True ) centered = samples - samples.mean(axis=0) - expected = centered.T @ centered.conj() / 2 - assert np.allclose(estimate.covariance, expected) - assert np.allclose(estimate.covariance, estimate.covariance.conj().T) + expected = centered.T @ centered.conj() / 4 + assert np.allclose(estimate.sample_covariance, expected) + assert np.allclose( + estimate.sample_covariance, estimate.sample_covariance.conj().T + ) assert not np.allclose(expected, expected.conj()) - assert np.allclose(estimate.variance, np.diag(expected).real) + assert np.allclose(estimate.sample_variance, np.diag(expected).real) + assert np.allclose(estimate.mean_variance, np.diag(expected).real / 5) + assert np.allclose(estimate.mean_covariance, expected / 5) assert estimate.diagnostics["pairing"] == "same_index" def test_dense_covariance_has_an_explicit_size_guard() -> None: - repeats = np.zeros((2, 1, 1, 2, 2), dtype=np.complex128) + repeats = np.zeros((6, 1, 1, 2, 2), dtype=np.complex128) with pytest.raises(ValueError, match="dense covariance"): estimate_repeat_differential_noise( repeats, @@ -47,6 +69,49 @@ def test_dense_covariance_has_an_explicit_size_guard() -> None: ) +def test_dense_covariance_rejects_insufficient_repeat_rank() -> None: + repeats = np.zeros((4, 1, 1, 2, 2), dtype=np.complex128) + with pytest.raises(ValueError, match="rank-deficient"): + estimate_repeat_differential_noise(repeats, repeats, full_covariance=True) + + diagonal = estimate_repeat_differential_noise(repeats, repeats) + assert diagonal.sample_covariance is None + + +@pytest.mark.parametrize( + ("changed", "message"), + [ + ({"frequency_hz": 5.1e9}, "frequency axes differ"), + ({"angle_deg": 1.0}, "angle axes differ"), + ({"port_label": "P2"}, "port labels differ"), + ], +) +def test_paired_dataset_repeats_reject_cross_stack_coordinate_mismatch( + changed: dict[str, float | str], message: str +) -> None: + reference = [_single_observation_dataset(0.0j) for _ in range(2)] + dut = [_single_observation_dataset(1.0 + 0.0j, **changed) for _ in range(2)] + + with pytest.raises(ValueError, match=message): + estimate_repeat_differential_noise(reference, dut) + + +def test_raw_repeat_arrays_require_complex_nonempty_axes_and_matching_representation() -> None: + real = np.zeros((2, 1, 1, 1, 1), dtype=np.float64) + strings = np.full((2, 1, 1, 1, 1), "1+2j") + empty = np.zeros((2, 0, 1, 1, 1), dtype=np.complex128) + complex_stack = np.zeros((2, 1, 1, 1, 1), dtype=np.complex128) + datasets = [_single_observation_dataset(0.0j) for _ in range(2)] + + for malformed in (real, strings): + with pytest.raises(ValueError, match="complex dtype"): + estimate_repeat_differential_noise(malformed, malformed) + with pytest.raises(ValueError, match="non-empty"): + estimate_repeat_differential_noise(empty, empty) + with pytest.raises(TypeError, match="both be raw arrays"): + estimate_repeat_differential_noise(complex_stack, datasets) + + def test_diagonal_whitening_scales_a_and_b_together() -> None: variance = np.asarray([4.0, 9.0]) noise = DiagonalNoiseModel.from_variance(variance) @@ -70,6 +135,18 @@ def test_zero_repeat_variance_requires_physical_absolute_floor() -> None: assert np.allclose(model.standard_deviation, 1.0e-6) +def test_repeat_estimate_mean_model_uses_variance_of_the_mean() -> None: + reference = np.zeros((3, 1, 1, 1, 1), dtype=np.complex128) + dut = np.asarray([-1.0, 0.0, 1.0], dtype=np.complex128).reshape(3, 1, 1, 1, 1) + estimate = estimate_repeat_differential_noise(reference, dut) + + assert estimate.sample_variance.item() == pytest.approx(1.0) + assert estimate.mean_variance.item() == pytest.approx(1.0 / 3.0) + assert estimate.mean_diagonal_model().standard_deviation.item() == pytest.approx( + np.sqrt(1.0 / 3.0) + ) + + def test_fixed_rank_complex_tsvd_recovers_exact_solution() -> None: matrix = np.asarray( [ @@ -112,6 +189,26 @@ def test_energy_and_discrepancy_rank_rules_are_auditable() -> None: assert discrepancy.residual_norm <= 1.1 +def test_discrepancy_selects_rank_zero_for_a_registered_null() -> None: + matrix = np.diag(np.asarray([4.0, 2.0, 1.0])) + observations = np.asarray([0.2 + 0.1j, -0.1j, 0.05]) + noise_norm = float(np.linalg.norm(observations)) + + solution = tsvd_solve( + matrix, + observations, + method="discrepancy", + noise_norm=noise_norm, + ) + + assert solution.selected_rank == 0 + assert solution.selected_condition_number is None + assert solution.target_met is True + np.testing.assert_array_equal(solution.criterion_ranks, np.arange(4)) + np.testing.assert_array_equal(solution.x, np.zeros(3, dtype=np.complex128)) + np.testing.assert_array_equal(solution.predicted, np.zeros(3, dtype=np.complex128)) + + def test_gcv_returns_finite_selection_and_curve() -> None: matrix = np.asarray( [ @@ -124,11 +221,28 @@ def test_gcv_returns_finite_selection_and_curve() -> None: ) observations = np.asarray([4.0, 2.0, 0.2, 0.15, 0.11]) solution = tsvd_solve(matrix, observations, method="gcv") - assert 1 <= solution.selected_rank <= solution.available_rank + assert 0 <= solution.selected_rank <= solution.available_rank assert np.all(np.isfinite(solution.criterion_values)) assert solution.criterion_ranks.shape == solution.criterion_values.shape +def test_gcv_can_select_rank_zero_for_orthogonal_null_noise() -> None: + matrix = np.asarray( + [ + [1.0, 0.0], + [0.0, 1.0], + [0.0, 0.0], + [0.0, 0.0], + ] + ) + observations = np.asarray([0.0, 0.0, 1.0, -1.0]) + solution = tsvd_solve(matrix, observations, method="gcv") + + assert solution.selected_rank == 0 + assert solution.selected_condition_number is None + np.testing.assert_array_equal(solution.x, np.zeros(2, dtype=np.complex128)) + + def test_tsvd_rejects_ambiguous_or_unusable_settings() -> None: matrix = np.eye(2) observations = np.ones(2) @@ -136,5 +250,37 @@ def test_tsvd_rejects_ambiguous_or_unusable_settings() -> None: tsvd_solve(matrix, observations, method="gcv", rank=1) with pytest.raises(ValueError, match="noise_norm"): tsvd_solve(matrix, observations, method="discrepancy") + with pytest.raises(ValueError, match="only valid"): + tsvd_solve(matrix, observations, method="gcv", noise_norm=1.0) + with pytest.raises(ValueError, match="only valid"): + tsvd_solve(matrix, observations, method="gcv", energy_fraction=0.9) with pytest.raises(np.linalg.LinAlgError, match="nonzero"): tsvd_solve(np.zeros((2, 2)), observations) + + +def test_seeded_complex_discrepancy_curve_matches_direct_residuals() -> None: + rng = np.random.default_rng(20260712) + for rows, columns in ((3, 2), (5, 3), (3, 5), (8, 4)): + for _ in range(10): + matrix = rng.normal(size=(rows, columns)) + 1j * rng.normal( + size=(rows, columns) + ) + observations = rng.normal(size=rows) + 1j * rng.normal(size=rows) + full = tsvd_solve( + matrix, + observations, + method="discrepancy", + noise_norm=0.0, + ) + u, singular_values, vh = np.linalg.svd(matrix, full_matrices=False) + direct = [] + for rank in full.criterion_ranks: + if rank == 0: + estimate = np.zeros(columns, dtype=np.complex128) + else: + estimate = ( + vh[:rank].conj().T + @ ((u[:, :rank].conj().T @ observations) / singular_values[:rank]) + ) + direct.append(np.linalg.norm(observations - matrix @ estimate)) + np.testing.assert_allclose(full.criterion_values, direct, rtol=1e-11, atol=1e-12) diff --git a/tests/test_measurement.py b/tests/test_measurement.py index 7030ffd..7fecb24 100644 --- a/tests/test_measurement.py +++ b/tests/test_measurement.py @@ -5,6 +5,7 @@ import numpy as np import pytest +import scatter3d.measurement as measurement_module from scatter3d.measurement import ( CSV_COLUMNS, ScatteringDataset, @@ -44,6 +45,13 @@ def test_dataset_contract_and_vector_order_are_explicit() -> None: angles_deg=dataset.angles_deg, port_labels=dataset.port_labels, ) + with pytest.raises(ValueError, match="whitespace"): + ScatteringDataset( + dataset.s, + frequencies_hz=dataset.frequencies_hz, + angles_deg=dataset.angles_deg, + port_labels=("P1", " P2"), + ) def test_csv_roundtrip_is_lossless_and_hashed(tmp_path: Path) -> None: @@ -91,6 +99,38 @@ def test_csv_rejects_schema_and_row_order_ambiguity(tmp_path: Path) -> None: read_scattering_csv(bad_header) +def test_csv_rejects_noncanonical_labels_and_huge_sparse_indices(tmp_path: Path) -> None: + path = tmp_path / "measurement.csv" + write_scattering_csv(path, make_dataset(angles=1, frequencies=1)) + lines = path.read_text(encoding="utf-8").splitlines() + + whitespace = list(lines) + fields = whitespace[1].split(",") + fields[5] = " P1" + whitespace[1] = ",".join(fields) + path.write_text("\n".join(whitespace) + "\n", encoding="utf-8") + with pytest.raises(ValueError, match="whitespace"): + read_scattering_csv(path) + + huge_index = list(lines) + fields = huge_index[1].split(",") + fields[0] = "1000000000" + huge_index[1] = ",".join(fields) + path.write_text("\n".join(huge_index) + "\n", encoding="utf-8") + with pytest.raises(ValueError, match="contiguous"): + read_scattering_csv(path) + + +def test_csv_rejects_a_file_that_changes_during_parse(tmp_path: Path, monkeypatch) -> None: + path = tmp_path / "measurement.csv" + write_scattering_csv(path, make_dataset(angles=1, frequencies=1)) + digests = iter(("0" * 64, "1" * 64)) + monkeypatch.setattr(measurement_module, "sha256_file", lambda _path: next(digests)) + + with pytest.raises(OSError, match="changed while"): + read_scattering_csv(path) + + def test_same_index_differential_is_default_and_alignment_is_off() -> None: reference = make_dataset() contrast = np.full(reference.shape, 0.125 - 0.25j) diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 1399675..b165197 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -5,12 +5,15 @@ import numpy as np import pytest +import scatter3d.pipeline as pipeline_module from scatter3d.cli import main from scatter3d.pipeline import ( SCHEMA_VERSION, SENSITIVITY_SCHEMA_VERSION, diagnose_measurement_bundle, + load_measurement_bundle, reconstruct_from_bundle, + schema_description, validate_measurement_bundle, ) @@ -97,10 +100,25 @@ def test_reconstruct_bundle_checks_coordinates_and_recovers_complex_truth(tmp_pa rank=voxels, ) assert report.selected_rank == voxels + assert report.status == "PASSED" + assert report.available_rank == voxels + assert report.singular_values_sha256 + assert report.criterion_sha256 assert report.relative_residual < 1e-12 with np.load(output, allow_pickle=False) as archive: np.testing.assert_allclose(archive["estimate"], truth, rtol=1e-12, atol=1e-12) assert str(archive["bundle_sha256"].item()) == report.bundle_sha256 + assert archive["singular_values"].shape == (voxels,) + assert archive["criterion_ranks"].shape == archive["criterion_values"].shape + assert str(archive["status"].item()) == "PASSED" + with pytest.raises(FileExistsError, match="refusing to overwrite"): + reconstruct_from_bundle( + bundle, + sensitivity, + output, + method="fixed", + rank=voxels, + ) def test_reconstruct_rejects_coordinate_mismatch(tmp_path): @@ -162,6 +180,47 @@ def test_reconstruct_auto_whitens_paired_repeat_mean(tmp_path): assert archive["noise_standard_deviation"].shape == (4,) +def test_discrepancy_whitening_preserves_a_null_as_rank_zero(tmp_path): + bundle = tmp_path / "measurement.npz" + sensitivity = tmp_path / "sensitivity.npz" + output = tmp_path / "out.npz" + offsets = np.asarray([-0.02 - 0.01j, 0.0 + 0.0j, 0.02 + 0.01j])[:, None] + reference = np.zeros((3, 1, 1, 2, 2), dtype=np.complex128) + dut = np.broadcast_to(offsets, (3, 4)).reshape(3, 1, 1, 2, 2).copy() + _write_bundle( + bundle, + reference_s=reference, + dut_s=dut, + frequencies_hz=np.array([5.0e9]), + angles_deg=np.array([0.0]), + labels=("P1", "P2"), + ) + np.savez_compressed( + sensitivity, + schema_version=np.array(SENSITIVITY_SCHEMA_VERSION), + A=np.eye(4, dtype=np.complex128), + frequencies_hz=np.array([5.0e9]), + angles_deg=np.array([0.0]), + port_labels=np.array(["P1", "P2"]), + ) + + report = reconstruct_from_bundle( + bundle, + sensitivity, + output, + method="discrepancy", + ) + + assert report.whitening_used + assert report.selected_rank == 0 + assert report.noise_norm_used == pytest.approx(2.0) + assert report.noise_norm_basis == "whitened_expected_rms_sqrt_rows" + with np.load(output, allow_pickle=False) as archive: + np.testing.assert_array_equal( + archive["estimate"], np.zeros(4, dtype=np.complex128) + ) + + def test_cli_validate_writes_machine_readable_report(tmp_path, capsys): bundle = tmp_path / "measurement.npz" report_path = tmp_path / "validation.json" @@ -184,3 +243,209 @@ def test_cli_validate_writes_machine_readable_report(tmp_path, capsys): def test_cli_returns_two_for_invalid_input(tmp_path, capsys): assert main(["validate", str(tmp_path / "missing.npz")]) == 2 assert "error" in capsys.readouterr().err + + +@pytest.mark.parametrize( + ("key", "replacement", "message"), + [ + ("reference_s", np.zeros((1, 1, 1, 1, 1), dtype=np.complex64), "complex128"), + ("frequencies_hz", np.array([5.0e9], dtype=np.float32), "float64"), + ("angles_deg", np.array([0], dtype=np.int64), "float64"), + ("port_labels", np.array([b"P1"]), "Unicode"), + ], +) +def test_measurement_npz_rejects_schema_dtype_coercion( + tmp_path, key, replacement, message +): + path = tmp_path / "measurement.npz" + values = { + "schema_version": np.array(SCHEMA_VERSION), + "reference_s": np.zeros((1, 1, 1, 1, 1), dtype=np.complex128), + "dut_s": np.zeros((1, 1, 1, 1, 1), dtype=np.complex128), + "frequencies_hz": np.array([5.0e9], dtype=np.float64), + "angles_deg": np.array([0.0], dtype=np.float64), + "port_labels": np.array(["P1"]), + } + values[key] = replacement + np.savez_compressed(path, **values) + + with pytest.raises(ValueError, match=message): + load_measurement_bundle(path) + + +@pytest.mark.parametrize( + ("matrix", "row_indices", "message"), + [ + (np.eye(4), None, "complex128"), + (np.eye(4, dtype=np.complex128), np.array([0.9, 1.9, 2.9, 3.9]), "int64"), + (np.eye(4, dtype=np.complex128), np.arange(4, dtype=np.int32), "int64"), + (np.eye(4, dtype=np.complex128), np.array([True, False, True, False]), "int64"), + ], +) +def test_sensitivity_npz_rejects_matrix_and_row_identity_coercion( + tmp_path, matrix, row_indices, message +): + bundle = tmp_path / "measurement.npz" + sensitivity = tmp_path / "sensitivity.npz" + scattering = np.zeros((1, 1, 1, 2, 2), dtype=np.complex128) + _write_bundle( + bundle, + reference_s=scattering, + dut_s=scattering, + frequencies_hz=np.array([5.0e9]), + angles_deg=np.array([0.0]), + labels=("P1", "P2"), + ) + payload = { + "schema_version": np.array(SENSITIVITY_SCHEMA_VERSION), + "A": matrix, + "frequencies_hz": np.array([5.0e9]), + "angles_deg": np.array([0.0]), + "port_labels": np.array(["P1", "P2"]), + } + if row_indices is not None: + payload["row_indices"] = row_indices + np.savez_compressed(sensitivity, **payload) + + with pytest.raises(ValueError, match=message): + reconstruct_from_bundle( + bundle, sensitivity, tmp_path / "out.npz", method="fixed", rank=1 + ) + + +def test_unmet_discrepancy_is_forensic_failed_artifact_and_nonzero_cli(tmp_path, capsys): + bundle = tmp_path / "measurement.npz" + sensitivity = tmp_path / "sensitivity.npz" + reference = np.zeros((1, 1, 1, 2, 2), dtype=np.complex128) + dut = np.array([0.0, 1.0, 0.0, 0.0], dtype=np.complex128).reshape( + 1, 1, 1, 2, 2 + ) + _write_bundle( + bundle, + reference_s=reference, + dut_s=dut, + frequencies_hz=np.array([5.0e9]), + angles_deg=np.array([0.0]), + labels=("P1", "P2"), + ) + np.savez_compressed( + sensitivity, + schema_version=np.array(SENSITIVITY_SCHEMA_VERSION), + A=np.array([[1.0], [0.0], [0.0], [0.0]], dtype=np.complex128), + frequencies_hz=np.array([5.0e9]), + angles_deg=np.array([0.0]), + port_labels=np.array(["P1", "P2"]), + ) + + report = reconstruct_from_bundle( + bundle, + sensitivity, + tmp_path / "library-out.npz", + method="discrepancy", + noise_norm=0.0, + whitening="off", + ) + assert report.status == "FAILED" + assert report.selection_target_met is False + with np.load(report.output_path, allow_pickle=False) as archive: + assert str(archive["status"].item()) == "FAILED" + assert int(archive["selection_target_met"]) == 0 + + base_args = [ + "invert", + str(bundle), + str(sensitivity), + str(tmp_path / "cli-out.npz"), + "--method", + "discrepancy", + "--noise-norm", + "0", + "--whitening", + "off", + ] + assert main(base_args) == 1 + assert json.loads(capsys.readouterr().out)["status"] == "FAILED" + allowed = [*base_args] + allowed[3] = str(tmp_path / "cli-allowed.npz") + allowed.append("--allow-unmet-discrepancy") + assert main(allowed) == 0 + assert json.loads(capsys.readouterr().out)["status"] == "FAILED" + + +def test_diagnostics_expose_bad_frequency_channel_hidden_by_global_rms(tmp_path): + bundle = tmp_path / "measurement.npz" + repeat_offsets = np.array([-1.0, -0.25, 0.25, 1.0])[:, None] + reference = np.zeros((4, 2, 1, 2, 2), dtype=np.complex128) + mean = np.array([10.0, 0.1, 1.0, 1.0], dtype=np.complex128) + noise_scale = np.array([0.01, 2.0, 0.1, 0.1], dtype=np.complex128) + dut_flat = mean[None, :] + repeat_offsets * noise_scale[None, :] + dut = np.broadcast_to(dut_flat[:, None, :], (4, 2, 4)).reshape(4, 2, 1, 2, 2) + _write_bundle( + bundle, + reference_s=reference, + dut_s=dut, + frequencies_hz=np.array([5.0e9]), + angles_deg=np.array([0.0, 15.0]), + labels=("P1", "P2"), + ) + + report = diagnose_measurement_bundle(bundle) + by_pair = { + (item.receiver_index, item.source_index): item + for item in report.frequency_channels + } + assert report.signal_to_repeat_noise is not None + assert by_pair[(0, 0)].signal_to_repeat_noise > 100.0 + assert by_pair[(0, 1)].signal_to_repeat_noise < 0.1 + + +def test_bundle_hash_detects_mutation_and_schema_lists_reconstruction(tmp_path, monkeypatch): + bundle = tmp_path / "measurement.npz" + scattering = np.zeros((1, 1, 1, 1, 1), dtype=np.complex128) + _write_bundle( + bundle, + reference_s=scattering, + dut_s=scattering, + frequencies_hz=np.array([5.0e9]), + angles_deg=np.array([0.0]), + labels=("P1",), + ) + digests = iter(("0" * 64, "1" * 64)) + monkeypatch.setattr(pipeline_module, "sha256_file", lambda _path: next(digests)) + with pytest.raises(OSError, match="changed while"): + load_measurement_bundle(bundle) + + assert "reconstruction" in schema_description() + + +def test_sensitivity_hash_detects_mutation_during_parse(tmp_path, monkeypatch): + bundle = tmp_path / "measurement.npz" + sensitivity = tmp_path / "sensitivity.npz" + scattering = np.zeros((1, 1, 1, 1, 1), dtype=np.complex128) + _write_bundle( + bundle, + reference_s=scattering, + dut_s=scattering, + frequencies_hz=np.array([5.0e9]), + angles_deg=np.array([0.0]), + labels=("P1",), + ) + np.savez_compressed( + sensitivity, + schema_version=np.array(SENSITIVITY_SCHEMA_VERSION), + A=np.ones((1, 1), dtype=np.complex128), + frequencies_hz=np.array([5.0e9]), + angles_deg=np.array([0.0]), + port_labels=np.array(["P1"]), + ) + digests = iter(("0" * 64, "0" * 64, "1" * 64, "2" * 64)) + monkeypatch.setattr(pipeline_module, "sha256_file", lambda _path: next(digests)) + + with pytest.raises(OSError, match="sensitivity archive changed"): + reconstruct_from_bundle( + bundle, + sensitivity, + tmp_path / "out.npz", + method="fixed", + rank=1, + ) diff --git a/tests/test_provenance.py b/tests/test_provenance.py index bc77d0a..4ab5ade 100644 --- a/tests/test_provenance.py +++ b/tests/test_provenance.py @@ -55,6 +55,7 @@ def test_write_manifest_is_byte_stable_and_detects_tampering(tmp_path: Path) -> assert digest == manifest["manifest_sha256"] write_manifest(path, dict(reversed(list(manifest.items())))) assert path.read_bytes() == first_bytes + assert not list(tmp_path.glob("*.tmp")) assert json.loads(path.read_text(encoding="utf-8"))["manifest_sha256"] == digest tampered = dict(manifest) diff --git a/tests/test_validation_fem_smoke.py b/tests/test_validation_fem_smoke.py new file mode 100644 index 0000000..5bbdf08 --- /dev/null +++ b/tests/test_validation_fem_smoke.py @@ -0,0 +1,520 @@ +from __future__ import annotations + +import copy +import json +import os +import stat +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from validation import fem_smoke + + +def test_v2_status_and_gate_use_exact_public_vocabulary() -> None: + assert fem_smoke.SCHEMA == "scatter3d.validation.fem_smoke/v2" + assert fem_smoke._gate(True) == {"status": "PASSED", "passed": True} + assert fem_smoke._gate(False) == {"status": "FAILED", "passed": False} + + +def test_expected_global_dofs_gate_is_exact_or_explicitly_not_run() -> None: + assert fem_smoke._expected_global_dofs_gate(86_103, None) == { + "status": "NOT RUN", + "passed": None, + "reason": "--expected-global-dofs was not supplied", + "observed": 86_103, + } + assert fem_smoke._expected_global_dofs_gate(86_103, 86_103) == { + "status": "PASSED", + "passed": True, + "expected": 86_103, + "observed": 86_103, + } + assert fem_smoke._expected_global_dofs_gate(86_104, 86_103) == { + "status": "FAILED", + "passed": False, + "expected": 86_103, + "observed": 86_104, + } + + +def test_hierarchy_validation_preserves_one_level_default_and_requires_coarse_degree() -> None: + assert not fem_smoke._validate_hierarchy_configuration( + solver="iterative", + hierarchy="one-level-asm", + fine_degree=3, + coarse_degree=None, + iterative_local_pc=None, + ) + assert fem_smoke._validate_hierarchy_configuration( + solver="iterative", + hierarchy="p-multigrid", + fine_degree=3, + coarse_degree=1, + iterative_local_pc="lu", + ) + invalid = ( + { + "solver": "direct", + "hierarchy": "p-multigrid", + "fine_degree": 3, + "coarse_degree": 1, + "iterative_local_pc": None, + }, + { + "solver": "iterative", + "hierarchy": "one-level-asm", + "fine_degree": 3, + "coarse_degree": 1, + "iterative_local_pc": None, + }, + { + "solver": "iterative", + "hierarchy": "p-multigrid", + "fine_degree": 3, + "coarse_degree": None, + "iterative_local_pc": None, + }, + { + "solver": "iterative", + "hierarchy": "p-multigrid", + "fine_degree": 2, + "coarse_degree": 2, + "iterative_local_pc": None, + }, + { + "solver": "iterative", + "hierarchy": "p-multigrid", + "fine_degree": 3, + "coarse_degree": 1, + "iterative_local_pc": "ilu", + }, + ) + for values in invalid: + with pytest.raises(ValueError): + fem_smoke._validate_hierarchy_configuration(**values) + + +def test_solver_counts_are_hierarchy_aware() -> None: + direct = fem_smoke._expected_solver_counts( + frequencies=2, + ports=2, + solver="direct", + uses_p_multigrid=False, + absorption_shift=0.0, + ) + assert direct["global_numeric_factorizations"] == 2 + assert direct["coarse_global_factorizations"] == 0 + assert direct["transfer_operator_assemblies"] == 0 + + one_level = fem_smoke._expected_solver_counts( + frequencies=2, + ports=2, + solver="iterative", + uses_p_multigrid=False, + absorption_shift=0.5, + ) + assert one_level["preconditioner_matrix_assemblies"] == 2 + assert one_level["coarse_preconditioner_matrix_assemblies"] == 0 + + p_multigrid = fem_smoke._expected_solver_counts( + frequencies=2, + ports=2, + solver="iterative", + uses_p_multigrid=True, + absorption_shift=0.5, + ) + assert p_multigrid == { + "matrix_assemblies": 2, + "preconditioner_matrix_assemblies": 2, + "coarse_preconditioner_matrix_assemblies": 2, + "transfer_operator_assemblies": 1, + "operator_setups": 2, + "global_numeric_factorizations": 0, + "coarse_global_factorizations": 2, + "rhs_solves": 4, + } + assert fem_smoke._observed_solver_counts(SimpleNamespace(**p_multigrid)) == p_multigrid + + +def test_p_multigrid_gate_is_not_run_outside_hierarchy_and_checks_transfer() -> None: + not_run = fem_smoke._p_multigrid_gate( + (), enabled=False, coarse_degree=None + ) + assert not_run["status"] == "NOT RUN" + assert not_run["passed"] is None + + diagnostic = { + "global_complex_dofs": 1_158, + "coarse_degree": 1, + "coarse_global_complex_dofs": 98, + "coarse_preconditioner_matrix_nonzeros": 1_024, + "p_multigrid_operator_checks_passed": True, + "transfer_operator": { + "direction": "coarse_to_fine", + "rows": 1_158, + "columns": 98, + "nonzeros": 2_048, + "constrained_fine_rows": 48, + "constrained_coarse_columns": 12, + "maximum_imaginary_abs": 0.0, + }, + "solver_hierarchy": { + "effective": { + "preconditioning_side": "right", + "pc_uses_amat": False, + "top_level": {"ksp_type": "fgmres", "pc_type": "mg"}, + "mg_levels": 2, + "mg_type": "multiplicative", + "mg_cycle_type": "v", + "mg_galerkin": "none", + "mg_fine_smoother": { + "ksp_type": "richardson", + "pc_type": "asm", + "maximum_iterations": 1, + "norm_type": "none", + }, + "mg_fine_asm_type": "restrict", + "mg_fine_asm_overlap": 1, + "mg_fine_asm_subdomain_solvers": [ + { + "ksp_type": "preonly", + "pc_type": "lu", + "factor_solver_type": "mumps", + } + ], + "mg_coarse_solver": { + "ksp_type": "preonly", + "pc_type": "lu", + "factor_solver_type": "mumps", + }, + } + }, + } + passed = fem_smoke._p_multigrid_gate( + (diagnostic,), enabled=True, coarse_degree=1 + ) + assert passed["status"] == "PASSED" + assert passed["passed"] is True + + diagnostic["transfer_operator"]["columns"] = 99 + failed = fem_smoke._p_multigrid_gate( + (diagnostic,), enabled=True, coarse_degree=1 + ) + assert failed["status"] == "FAILED" + assert failed["passed"] is False + + diagnostic["transfer_operator"]["columns"] = 98 + diagnostic["solver_hierarchy"]["effective"]["pc_uses_amat"] = True + failed_hierarchy = fem_smoke._p_multigrid_gate( + (diagnostic,), enabled=True, coarse_degree=1 + ) + assert failed_hierarchy["status"] == "FAILED" + + diagnostic["solver_hierarchy"]["effective"]["pc_uses_amat"] = False + diagnostic["transfer_operator"]["constrained_fine_rows"] = 0 + failed_mask = fem_smoke._p_multigrid_gate( + (diagnostic,), enabled=True, coarse_degree=1 + ) + assert failed_mask["status"] == "FAILED" + + +def test_image_digests_are_explicitly_sourced_or_null() -> None: + missing = fem_smoke._image_digest_metadata({}) + assert missing["project_image"] == { + "digest": None, + "local_image_id": None, + "provenance": "not_provided", + "environment_variable": None, + } + project_image_id = "sha256:" + "a" * 64 + base_digest = "sha256:" + "b" * 64 + supplied = fem_smoke._image_digest_metadata( + { + "SCATTER3D_PROJECT_IMAGE_ID": f" {project_image_id.upper()} ", + "SCATTER3D_BASE_IMAGE_DIGEST": base_digest, + } + ) + assert supplied["project_image"]["digest"] is None + assert supplied["project_image"]["local_image_id"] == project_image_id + assert ( + supplied["project_image"]["provenance"] + == "docker_local_image_id_environment" + ) + assert supplied["base_image"]["digest"] == base_digest + + +@pytest.mark.parametrize( + "variable,value", + ( + ("SCATTER3D_PROJECT_IMAGE_DIGEST", "sha256:project"), + ("SCATTER3D_PROJECT_IMAGE_ID", "sha256:" + "g" * 64), + ("SCATTER3D_BASE_IMAGE_DIGEST", "not-a-digest"), + ), +) +def test_image_metadata_rejects_malformed_supplied_values( + variable: str, value: str +) -> None: + with pytest.raises(ValueError, match="sha256"): + fem_smoke._image_digest_metadata({variable: value}) + + +def test_image_metadata_rejects_ambiguous_project_identity() -> None: + identity = "sha256:" + "a" * 64 + with pytest.raises(ValueError, match="only one"): + fem_smoke._image_digest_metadata( + { + "SCATTER3D_PROJECT_IMAGE_DIGEST": identity, + "SCATTER3D_PROJECT_IMAGE_ID": identity, + } + ) + + +def test_cgroup_v2_peak_and_unlimited_limit_are_recorded(tmp_path: Path) -> None: + (tmp_path / "memory.peak").write_text("12345\n", encoding="utf-8") + (tmp_path / "memory.max").write_text("max\n", encoding="utf-8") + (tmp_path / "memory.swap.max").write_text("0\n", encoding="utf-8") + + metadata = fem_smoke._cgroup_memory_metadata(tmp_path) + + assert metadata["version"] == "v2" + assert metadata["peak_bytes"] == 12345 + assert metadata["limit_bytes"] is None + assert metadata["swap_limit_bytes"] == 0 + assert metadata["provenance"] == "cgroup_files" + + +def test_cgroup_metadata_does_not_invent_unavailable_values(tmp_path: Path) -> None: + assert fem_smoke._cgroup_memory_metadata(tmp_path) == { + "version": None, + "peak_bytes": None, + "limit_bytes": None, + "swap_limit_bytes": None, + "peak_source": None, + "limit_source": None, + "swap_limit_source": None, + "provenance": "unavailable", + } + + +def test_command_metadata_preserves_argv_order(monkeypatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + metadata = fem_smoke._command_metadata( + ["validation/fem_smoke.py", "--solver", "iterative"] + ) + assert metadata == { + "argv": ["validation/fem_smoke.py", "--solver", "iterative"], + "working_directory": str(tmp_path), + "provenance": "process", + } + + +def test_git_metadata_uses_only_validated_complete_environment_fallback( + monkeypatch, + tmp_path: Path, +) -> None: + def git_unavailable(*args, **kwargs): + raise FileNotFoundError("git unavailable in image") + + monkeypatch.setattr(fem_smoke.subprocess, "run", git_unavailable) + commit = "a" * 40 + metadata = fem_smoke._git_metadata( + tmp_path, + { + "SCATTER3D_GIT_COMMIT": commit.upper(), + "SCATTER3D_GIT_DIRTY": "false", + }, + ) + assert metadata["commit"] == commit + assert metadata["dirty"] is False + assert metadata["provenance"] == "environment" + + invalid_environments = ( + {"SCATTER3D_GIT_COMMIT": commit}, + {"SCATTER3D_GIT_DIRTY": "false"}, + { + "SCATTER3D_GIT_COMMIT": "short", + "SCATTER3D_GIT_DIRTY": "false", + }, + { + "SCATTER3D_GIT_COMMIT": commit, + "SCATTER3D_GIT_DIRTY": "0", + }, + ) + for environment in invalid_environments: + with pytest.raises(ValueError): + fem_smoke._git_metadata(tmp_path, environment) + + +class _Canonical: + def __init__(self, payload: dict) -> None: + self.payload = payload + + def canonical(self) -> dict: + return self.payload + + +def test_physical_problem_identity_is_deterministic_and_covers_mutations() -> None: + kwargs = { + "subdivisions": 9, + "frequencies_hz": [1.0e8], + "problem_config": _Canonical( + { + "polynomial_degree": 3, + "geometry_order": 1, + "quadrature_degree": 6, + "time_convention": "exp(-i*omega*t)", + } + ), + "material_map": _Canonical({"default": {"relative_permittivity": [2, 0]}}), + "contract": _Canonical( + {"volumes": {"domain": 1}, "boundaries": {"ports": {"left": 10}}} + ), + "port_definitions": [_Canonical({"name": "left", "facet_tag": 10})], + } + first = fem_smoke._physical_problem_metadata(**kwargs) + second = fem_smoke._physical_problem_metadata(**kwargs) + assert first == second + assert len(first["sha256"]) == 64 + + changed = dict(kwargs) + changed["subdivisions"] = 10 + assert fem_smoke._physical_problem_metadata(**changed)["sha256"] != first["sha256"] + + +def _comparison_pair() -> tuple[dict, dict]: + physical_problem = fem_smoke._canonical_identity( + { + "mesh": {"subdivisions_xyz": [9, 9, 9]}, + "discretization": {"polynomial_degree": 3, "geometry_order": 1}, + "frequencies_hz": [1.0e8], + "materials": {"relative_permittivity": [2.0, 0.0]}, + "mesh_tags_and_boundaries": {"ports": {"left": 10, "right": 11}}, + "ports": [{"name": "left"}, {"name": "right"}], + "operator_convention": {"time_convention": "exp(-i*omega*t)"}, + } + ) + common = { + "source": {"commit": "a" * 40, "dirty": False}, + "images": { + "project_image": {"digest": "sha256:" + "b" * 64}, + "base_image": {"digest": "sha256:" + "c" * 64}, + }, + "runtime": { + "dolfinx_version": "0.10.0", + "petsc_version": [3, 24, 0], + "petsc_scalar_type": "complex128", + "mpi_library_version": "MPICH 4.3.1", + }, + "physical_problem": physical_problem, + "mpi_size": 4, + "frequency_diagnostics": [ + { + "frequency_hz": 1.0e8, + "global_complex_dofs": 86_103, + "matrix_nonzeros": 7_038_009, + "rank_peak_rss_bytes_sum": 1_000, + } + ], + } + iterative = {"solver": "iterative", **copy.deepcopy(common)} + direct = { + "solver": "direct", + "status": "PASSED", + "passed": True, + "gates": { + "convergence_and_true_residual": {"status": "PASSED", "passed": True} + }, + **copy.deepcopy(common), + } + return iterative, direct + + +def test_direct_comparison_accepts_only_complete_same_problem_evidence() -> None: + iterative, direct = _comparison_pair() + fem_smoke._validate_direct_comparison(iterative, direct) + + mutations = ( + lambda value: value.update(status="FAILED", passed=False), + lambda value: value["gates"]["convergence_and_true_residual"].update( + status="FAILED", passed=False + ), + lambda value: value.update(mpi_size=8), + lambda value: value["source"].update(commit="d" * 40), + lambda value: value["source"].update(dirty=True), + lambda value: value["images"]["project_image"].update( + digest="sha256:" + "d" * 64 + ), + lambda value: value["runtime"].update(petsc_version=[3, 25, 0]), + lambda value: value["physical_problem"].update(sha256="0" * 64), + lambda value: value["frequency_diagnostics"][0].update( + global_complex_dofs=86_104 + ), + lambda value: value["frequency_diagnostics"][0].update( + matrix_nonzeros=7_038_010 + ), + ) + for mutation in mutations: + adversarial = copy.deepcopy(direct) + mutation(adversarial) + with pytest.raises(ValueError): + fem_smoke._validate_direct_comparison(iterative, adversarial) + + +def test_direct_comparison_rejects_missing_required_identity() -> None: + iterative, direct = _comparison_pair() + for key in ("source", "images", "runtime", "physical_problem", "frequency_diagnostics"): + adversarial = copy.deepcopy(direct) + del adversarial[key] + with pytest.raises(ValueError): + fem_smoke._validate_direct_comparison(iterative, adversarial) + + +def test_release_provenance_gate_is_explicit_and_not_a_general_solve_gate() -> None: + iterative, _ = _comparison_pair() + passed = fem_smoke._release_provenance_gate(iterative) + assert passed == { + "status": "PASSED", + "passed": True, + "scope": "release_and_comparison_only", + } + + iterative["images"]["base_image"]["digest"] = None + failed = fem_smoke._release_provenance_gate(iterative) + assert failed["status"] == "FAILED" + assert failed["passed"] is False + assert failed["scope"] == "release_and_comparison_only" + + +def test_nonfinite_diagnostics_are_explicit_strict_json() -> None: + safe = fem_smoke._json_safe( + { + "residual": float("nan"), + "history": [float("inf"), float("-inf"), 1.0], + } + ) + assert safe == { + "residual": "NaN", + "history": ["+Infinity", "-Infinity", 1.0], + } + assert json.loads(json.dumps(safe, allow_nan=False)) == safe + + +def test_artifact_write_is_atomic_no_clobber_by_default(tmp_path: Path) -> None: + output = tmp_path / "evidence.json" + fem_smoke._write_payload({"status": "FAILED"}, output) + original = output.read_bytes() + with pytest.raises(FileExistsError, match="refusing to overwrite"): + fem_smoke._write_payload({"status": "PASSED"}, output) + assert output.read_bytes() == original + + fem_smoke._write_payload({"status": "PASSED"}, output, overwrite=True) + assert json.loads(output.read_text(encoding="utf-8"))["status"] == "PASSED" + + +@pytest.mark.skipif(os.name != "posix", reason="POSIX artifact mode contract") +def test_completed_validation_artifact_is_publicly_readable(tmp_path: Path) -> None: + output = tmp_path / "public-evidence.json" + fem_smoke._write_payload({"status": "PASSED"}, output) + assert stat.S_IMODE(output.stat().st_mode) == 0o644 diff --git a/tests/test_validation_register_scaling_sweep.py b/tests/test_validation_register_scaling_sweep.py new file mode 100644 index 0000000..a7a0b72 --- /dev/null +++ b/tests/test_validation_register_scaling_sweep.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +import copy +import hashlib +import json +import os +import stat +from pathlib import Path + +import pytest + +from validation import register_scaling_sweep + +SPEC_PATH = Path(__file__).parents[1] / "validation" / "scaling_sweep_v1.json" +RUNTIME_METADATA = { + "dolfinx": "0.10.0", + "petsc": "3.24.0", + "petsc_scalar_type": "complex128", +} + + +def _spec() -> tuple[dict, bytes]: + encoded = SPEC_PATH.read_bytes() + return json.loads(encoded), encoded + + +def _registration() -> dict: + spec, encoded = _spec() + return register_scaling_sweep.build_registration( + spec, + encoded, + source={"commit": "a" * 40, "dirty": False}, + project_image_kind="local_image_id", + project_image_identity="sha256:" + "b" * 64, + base_image_digest="sha256:" + "c" * 64, + base_runtime_metadata=RUNTIME_METADATA, + ) + + +def test_static_sweep_spec_is_the_exact_immutable_experiment() -> None: + spec, _ = _spec() + register_scaling_sweep.validate_scaling_sweep_spec(spec) + assert spec["status_vocabulary"] == ["PASSED", "FAILED", "NOT RUN", "BLOCKED"] + assert spec["preconditioner_absorption_shifts"] == [0.0, 0.25, 0.5, 1.0] + assert [(item["subdivisions"], item["mpi_ranks"], item["gmres_restart"]) for item in spec["rungs"]] == [ + (9, 4, 80), + (16, 8, 100), + ] + assert spec["physical_problem"] == { + "fine_degree": 3, + "coarse_degree": 1, + "frequency_hz": 100_000_000.0, + "ports": ["left", "right"], + } + assert spec["cgroup_memory_limit_bytes"] == 28 * 1024**3 + + +@pytest.mark.parametrize( + "mutation", + ( + lambda value: value.update(status_vocabulary=["PASSED", "FAILED"]), + lambda value: value.update(preconditioner_absorption_shifts=[0.5]), + lambda value: value.update(cgroup_memory_limit_bytes=32 * 1024**3), + lambda value: value["rungs"][0].update(mpi_ranks=8), + lambda value: value["solver"].update(coarse_factor_solver="superlu_dist"), + ), +) +def test_spec_validator_rejects_any_registered_contract_mutation(mutation) -> None: + spec, _ = _spec() + mutation(spec) + with pytest.raises(ValueError, match="immutable v1 contract"): + register_scaling_sweep.validate_scaling_sweep_spec(spec) + + +def test_registration_is_deterministic_complete_and_initially_not_run() -> None: + first = _registration() + second = _registration() + assert first == second + assert first["schema"] == register_scaling_sweep.REGISTRATION_SCHEMA + assert first["status_vocabulary"] == ["PASSED", "FAILED", "NOT RUN", "BLOCKED"] + assert first["specification"]["sha256"] == hashlib.sha256( + SPEC_PATH.read_bytes() + ).hexdigest() + assert first["source"] == {"commit": "a" * 40, "dirty": False} + assert len(first["entries"]) == 8 + assert all(item["status"] == "NOT RUN" for item in first["entries"]) + assert all(item["passed"] is None for item in first["entries"]) + assert len({item["run_id"] for item in first["entries"]}) == 8 + assert len({item["outputs"]["fem_smoke_json"] for item in first["entries"]}) == 8 + + +def test_registered_commands_cover_every_fixed_shift_and_rung_exactly() -> None: + registration = _registration() + observed = set() + expected_restarts = {"p3-n9-mpi4": "80", "p3-n16-mpi8": "100"} + for entry in registration["entries"]: + command = entry["command"] + assert command[:5] == [ + "mpirun", + "-n", + str(entry["resource_contract"]["mpi_ranks"]), + "python3", + "validation/fem_smoke.py", + ] + assert "--iterative-hierarchy" in command + assert command[command.index("--iterative-hierarchy") + 1] == "p-multigrid" + assert command[command.index("--degree") + 1] == "3" + assert command[command.index("--p-multigrid-coarse-degree") + 1] == "1" + assert command[command.index("--iterative-local-pc") + 1] == "lu" + assert command[command.index("--maximum-iterations") + 1] == "1000" + assert command[command.index("--maximum-true-relative-residual") + 1] == "1e-07" + assert command[command.index("--frequencies-hz") + 1] == "100000000.0" + assert command[command.index("--gmres-restart") + 1] == expected_restarts[ + entry["rung_id"] + ] + assert command[command.index("--asm-overlap") + 1] == "1" + assert entry["resource_contract"]["cgroup_memory_limit_bytes"] == 28 * 1024**3 + assert command[-2:] == [ + "--output", + entry["outputs"]["container_fem_smoke_json"], + ] + assert command[command.index("--expected-global-dofs") + 1] in { + "86103", + "470928", + } + observed.add( + ( + entry["rung_id"], + entry["preconditioner_absorption_shift"], + ) + ) + assert observed == { + (rung, shift) + for rung in ("p3-n9-mpi4", "p3-n16-mpi8") + for shift in (0.0, 0.25, 0.5, 1.0) + } + + +def test_registration_rejects_malformed_source_and_image_identities() -> None: + spec, encoded = _spec() + kwargs = { + "source": {"commit": "a" * 40, "dirty": False}, + "project_image_kind": "oci_digest", + "project_image_identity": "sha256:" + "b" * 64, + "base_image_digest": "sha256:" + "c" * 64, + "base_runtime_metadata": RUNTIME_METADATA, + } + mutations = ( + lambda value: value.update(source={"commit": "short", "dirty": False}), + lambda value: value.update(source={"commit": "a" * 40, "dirty": "false"}), + lambda value: value.update(source={"commit": "a" * 40, "dirty": True}), + lambda value: value.update(project_image_kind="tag"), + lambda value: value.update(project_image_identity="latest"), + lambda value: value.update(base_image_digest="sha256:short"), + ) + for mutation in mutations: + adversarial = copy.deepcopy(kwargs) + mutation(adversarial) + with pytest.raises(ValueError): + register_scaling_sweep.build_registration(spec, encoded, **adversarial) + + +def test_registration_writer_is_deterministic_and_never_clobbers(tmp_path: Path) -> None: + payload = _registration() + output = tmp_path / "registration.json" + destination = register_scaling_sweep.write_registration(payload, output) + assert destination == output.resolve() + original = output.read_bytes() + assert json.loads(original) == payload + + with pytest.raises(FileExistsError, match="refusing to overwrite"): + register_scaling_sweep.write_registration(payload, output) + assert output.read_bytes() == original + + +def test_registration_identity_changes_all_output_paths() -> None: + baseline = _registration() + spec, encoded = _spec() + changed = register_scaling_sweep.build_registration( + spec, + encoded, + source={"commit": "d" * 40, "dirty": False}, + project_image_kind="local_image_id", + project_image_identity="sha256:" + "b" * 64, + base_image_digest="sha256:" + "c" * 64, + base_runtime_metadata=RUNTIME_METADATA, + ) + assert changed["registration_id"] != baseline["registration_id"] + assert changed["output_root"] != baseline["output_root"] + assert { + entry["outputs"]["directory"] for entry in changed["entries"] + }.isdisjoint( + entry["outputs"]["directory"] for entry in baseline["entries"] + ) + + +@pytest.mark.skipif(os.name != "posix", reason="POSIX registration mode contract") +def test_registration_is_published_world_readable(tmp_path: Path) -> None: + output = tmp_path / "registration.json" + register_scaling_sweep.write_registration(_registration(), output) + assert stat.S_IMODE(output.stat().st_mode) == 0o644 diff --git a/tests/test_validation_remote_capacity_preflight.py b/tests/test_validation_remote_capacity_preflight.py new file mode 100644 index 0000000..73edb39 --- /dev/null +++ b/tests/test_validation_remote_capacity_preflight.py @@ -0,0 +1,397 @@ +from __future__ import annotations + +import ast +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from validation import remote_capacity_preflight + + +def _statistics(*, total_blocks: int = 1_000, available_blocks: int = 500, size: int = 4_096): + return SimpleNamespace( + f_frsize=size, + f_blocks=total_blocks, + f_bavail=available_blocks, + ) + + +def _spec(label: str, path: Path) -> str: + return f"{label}={path.resolve()}" + + +def test_all_paths_pass_at_or_above_the_exact_threshold(tmp_path: Path) -> None: + docker = tmp_path / "docker" + evidence = tmp_path / "evidence" + docker.mkdir() + evidence.mkdir() + calls: list[Path] = [] + + def statvfs(path: Path): + calls.append(path) + return _statistics(available_blocks=10, size=100) + + report = remote_capacity_preflight.run_preflight( + [_spec("evidence", evidence), _spec("docker", docker)], + "1000", + statvfs=statvfs, + ) + + assert report["status"] == "PASSED" + assert report["passed"] is True + assert [check["label"] for check in report["checks"]] == ["docker", "evidence"] + assert all(check["available_bytes"] == 1_000 for check in report["checks"]) + assert all(check["shortfall_bytes"] == 0 for check in report["checks"]) + assert calls == [docker.resolve(), evidence.resolve()] + + +def test_one_low_space_filesystem_makes_the_whole_report_failed(tmp_path: Path) -> None: + enough = tmp_path / "enough" + low = tmp_path / "low" + enough.mkdir() + low.mkdir() + + def statvfs(path: Path): + available = 200 if path.name == "enough" else 99 + return _statistics(available_blocks=available, size=10) + + report = remote_capacity_preflight.run_preflight( + [_spec("scratch", enough), _spec("artifacts", low)], + 1_000, + statvfs=statvfs, + ) + + assert report["status"] == "FAILED" + assert report["passed"] is False + checks = {check["label"]: check for check in report["checks"]} + assert checks["scratch"]["status"] == "PASSED" + assert checks["artifacts"] == { + "available_bytes": 990, + "label": "artifacts", + "minimum_free_bytes": 1_000, + "passed": False, + "reason": "available bytes are below the required minimum", + "reason_code": "INSUFFICIENT_FREE_BYTES", + "shortfall_bytes": 10, + "status": "FAILED", + "total_bytes": 10_000, + } + + +@pytest.mark.parametrize("failure", (OSError("denied"), ValueError("bad stat"))) +def test_stat_errors_are_sanitized_failed_evidence(tmp_path: Path, failure: Exception) -> None: + target = tmp_path / "capacity" + target.mkdir() + + def statvfs(_path: Path): + raise failure + + report = remote_capacity_preflight.run_preflight( + [_spec("capacity", target)], + 1, + statvfs=statvfs, + ) + + assert report["status"] == "FAILED" + assert report["checks"][0]["reason_code"] == "STATVFS_FAILED" + assert report["checks"][0]["available_bytes"] is None + encoded = remote_capacity_preflight.encode_report(report) + assert "denied" not in encoded + assert "bad stat" not in encoded + assert str(target) not in encoded + + +@pytest.mark.parametrize( + ("statistics", "reason_code"), + ( + (_statistics(size=0), "STATVFS_FAILED"), + (_statistics(total_blocks=10, available_blocks=11), "STATVFS_FAILED"), + (SimpleNamespace(f_frsize=1, f_blocks=10), "STATVFS_FAILED"), + (SimpleNamespace(f_frsize=1.5, f_blocks=10, f_bavail=5), "STATVFS_FAILED"), + ), +) +def test_malformed_statvfs_results_fail_closed( + tmp_path: Path, statistics, reason_code: str +) -> None: + target = tmp_path / "capacity" + target.mkdir() + report = remote_capacity_preflight.run_preflight( + [_spec("capacity", target)], + 1, + statvfs=lambda _path: statistics, + ) + assert report["status"] == "FAILED" + assert report["checks"][0]["reason_code"] == reason_code + + +@pytest.mark.parametrize( + ("specifications", "error_code"), + ( + ([], "NO_PATHS"), + (["missing-separator"], "MALFORMED_PATH_SPECIFICATION"), + (["../unsafe=/tmp"], "UNSAFE_PATH_LABEL"), + (["UPPER=/tmp"], "UNSAFE_PATH_LABEL"), + (["with space=/tmp"], "UNSAFE_PATH_LABEL"), + (["label=relative/path"], "PATH_NOT_ABSOLUTE"), + ), +) +def test_malformed_inputs_are_rejected_as_formal_failed_json( + specifications: list[str], error_code: str +) -> None: + report = remote_capacity_preflight.run_preflight(specifications, 1) + assert report["status"] == "FAILED" + assert report["passed"] is False + assert report["checks"] == [] + assert report["error"]["code"] == error_code + json.loads(remote_capacity_preflight.encode_report(report)) + + +def test_duplicate_label_is_rejected_without_exposing_paths(tmp_path: Path) -> None: + first = tmp_path / "first-secret-name" + second = tmp_path / "second-secret-name" + first.mkdir() + second.mkdir() + report = remote_capacity_preflight.run_preflight( + [_spec("disk", first), _spec("disk", second)], + 1, + ) + encoded = remote_capacity_preflight.encode_report(report) + assert report["status"] == "FAILED" + assert report["error"]["code"] == "DUPLICATE_PATH_LABEL" + assert str(first) not in encoded + assert str(second) not in encoded + + +def test_absolute_path_containing_equals_is_supported(tmp_path: Path) -> None: + target = tmp_path / "docker=data" + target.mkdir() + report = remote_capacity_preflight.run_preflight( + [_spec("disk", target)], + 1, + statvfs=lambda _path: _statistics(), + ) + assert report["status"] == "PASSED" + + +def test_nonexistent_absolute_path_is_rejected(tmp_path: Path) -> None: + missing = (tmp_path / "credential-like-secret-name").resolve() + report = remote_capacity_preflight.run_preflight([f"disk={missing}"], 1) + encoded = remote_capacity_preflight.encode_report(report) + assert report["status"] == "FAILED" + assert report["error"]["code"] == "PATH_NOT_ACCESSIBLE" + assert str(missing) not in encoded + + +def test_nul_path_is_rejected_as_inaccessible_without_echo(tmp_path: Path) -> None: + malformed = f"{tmp_path.resolve()}\0credential-like-secret" + report = remote_capacity_preflight.run_preflight([f"disk={malformed}"], 1) + encoded = remote_capacity_preflight.encode_report(report) + assert report["status"] == "FAILED" + assert report["error"]["code"] == "PATH_NOT_ACCESSIBLE" + assert "credential-like-secret" not in encoded + + +@pytest.mark.parametrize( + ("targets", "error_code"), + ( + ([object()], "INVALID_CAPACITY_TARGET"), + ( + [remote_capacity_preflight.LabeledPath(label="../unsafe", path=Path.cwd())], + "UNSAFE_PATH_LABEL", + ), + ( + [remote_capacity_preflight.LabeledPath(label="disk", path="not-a-path")], + "INVALID_CAPACITY_TARGET", + ), + ( + [remote_capacity_preflight.LabeledPath(label="disk", path=Path("relative"))], + "PATH_NOT_ABSOLUTE", + ), + ), +) +def test_evaluator_rejects_untrusted_target_objects(targets, error_code: str) -> None: + with pytest.raises(remote_capacity_preflight.CapacityPreflightInputError) as captured: + remote_capacity_preflight.evaluate_capacity( + targets, + 1, + statvfs=lambda _path: _statistics(), + ) + assert captured.value.code == error_code + + +@pytest.mark.parametrize("threshold", (0, -1, True, "0", "-1", "+1", " 1", "1.0")) +def test_invalid_threshold_is_rejected(threshold) -> None: + report = remote_capacity_preflight.run_preflight([], threshold) + assert report["status"] == "FAILED" + assert report["error"]["code"] == "INVALID_MINIMUM_FREE_BYTES" + + +@pytest.mark.parametrize( + "threshold", + ( + "9" * 5_000, + 10**5_000, + remote_capacity_preflight.MAXIMUM_FREE_BYTES + 1, + ), + ids=("oversized-string", "oversized-int", "above-signed-64-bit"), +) +def test_threshold_has_an_explicit_module_owned_upper_bound(threshold) -> None: + report = remote_capacity_preflight.run_preflight([], threshold) + assert report["status"] == "FAILED" + assert report["error"]["code"] == "INVALID_MINIMUM_FREE_BYTES" + json.loads(remote_capacity_preflight.encode_report(report)) + + +@pytest.mark.parametrize( + "threshold", + ( + remote_capacity_preflight.MAXIMUM_FREE_BYTES, + str(remote_capacity_preflight.MAXIMUM_FREE_BYTES), + ), +) +def test_exact_signed_64_bit_maximum_is_accepted(tmp_path: Path, threshold) -> None: + assert remote_capacity_preflight.MAXIMUM_FREE_BYTES == (1 << 63) - 1 + target = tmp_path / "capacity" + target.mkdir() + report = remote_capacity_preflight.run_preflight( + [_spec("disk", target)], + threshold, + statvfs=lambda _path: _statistics( + total_blocks=remote_capacity_preflight.MAXIMUM_FREE_BYTES, + available_blocks=remote_capacity_preflight.MAXIMUM_FREE_BYTES, + size=1, + ), + ) + assert report["status"] == "PASSED" + assert report["minimum_free_bytes"] == remote_capacity_preflight.MAXIMUM_FREE_BYTES + + +def test_report_is_deterministic_and_contains_no_machine_identity(tmp_path: Path) -> None: + alpha = tmp_path / "alpha" + beta = tmp_path / "beta" + alpha.mkdir() + beta.mkdir() + + def statvfs(_path: Path): + return _statistics(available_blocks=8, size=128) + + first = remote_capacity_preflight.run_preflight( + [_spec("beta", beta), _spec("alpha", alpha)], 512, statvfs=statvfs + ) + second = remote_capacity_preflight.run_preflight( + [_spec("alpha", alpha), _spec("beta", beta)], 512, statvfs=statvfs + ) + first_json = remote_capacity_preflight.encode_report(first) + second_json = remote_capacity_preflight.encode_report(second) + assert first_json == second_json + assert str(alpha) not in first_json + assert str(beta) not in first_json + assert "hostname" not in first_json.lower() + + +def test_cli_returns_zero_only_for_all_passed(monkeypatch, capsys, tmp_path: Path) -> None: + target = tmp_path / "capacity" + target.mkdir() + + monkeypatch.setattr( + remote_capacity_preflight, + "_system_statvfs", + lambda _path: _statistics(available_blocks=2, size=100), + ) + passed_code = remote_capacity_preflight.main( + ["--path", _spec("disk", target), "--minimum-free-bytes", "200"] + ) + passed = json.loads(capsys.readouterr().out) + assert passed_code == 0 + assert passed["status"] == "PASSED" + + failed_code = remote_capacity_preflight.main( + ["--path", _spec("disk", target), "--minimum-free-bytes", "201"] + ) + failed = json.loads(capsys.readouterr().out) + assert failed_code == 1 + assert failed["status"] == "FAILED" + + +def test_cli_oversized_threshold_is_sanitized_failed_json(capsys, tmp_path: Path) -> None: + target = tmp_path / "capacity" + target.mkdir() + oversized = "9" * 5_000 + + return_code = remote_capacity_preflight.main( + ["--path", _spec("disk", target), "--minimum-free-bytes", oversized] + ) + captured = capsys.readouterr() + report = json.loads(captured.out) + + assert return_code == 1 + assert captured.err == "" + assert report["status"] == "FAILED" + assert report["error"] == { + "code": "INVALID_MINIMUM_FREE_BYTES", + "message": "minimum free bytes must be a positive base-10 integer", + } + assert oversized not in captured.out + + +@pytest.mark.parametrize( + "arguments", + ( + ["--unknown-option", "credential-like-secret", "--minimum-free-bytes", "1"], + ["--path"], + ["credential-like-secret", "--minimum-free-bytes", "1"], + ["--minimum-free-bytes"], + ["--minimum-free", "1"], + ), +) +def test_cli_parse_failures_are_sanitized_failed_json(arguments, capsys) -> None: + return_code = remote_capacity_preflight.main(arguments) + captured = capsys.readouterr() + report = json.loads(captured.out) + assert return_code == 1 + assert captured.err == "" + assert report["status"] == "FAILED" + assert report["passed"] is False + assert report["error"] == { + "code": "INVALID_COMMAND_LINE", + "message": "command-line arguments are invalid", + } + assert "credential-like-secret" not in captured.out + + +def test_implementation_has_no_shell_or_df_dependency(tmp_path: Path) -> None: + source_path = Path(remote_capacity_preflight.__file__) + tree = ast.parse(source_path.read_text(encoding="utf-8")) + imported_roots = { + alias.name.split(".", 1)[0] + for node in ast.walk(tree) + if isinstance(node, ast.Import) + for alias in node.names + } + imported_roots.update( + node.module.split(".", 1)[0] + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) and node.module is not None + ) + forbidden_os_calls = { + node.func.attr + for node in ast.walk(tree) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and isinstance(node.func.value, ast.Name) + and node.func.value.id == "os" + and node.func.attr in {"popen", "system"} + } + assert "subprocess" not in imported_roots + assert forbidden_os_calls == set() + + target = tmp_path / "capacity" + target.mkdir() + report = remote_capacity_preflight.run_preflight( + [_spec("disk", target)], + 1, + statvfs=lambda _path: _statistics(), + ) + assert report["status"] == "PASSED" diff --git a/tests/test_validation_run_registered_scaling_sweep.py b/tests/test_validation_run_registered_scaling_sweep.py new file mode 100644 index 0000000..8818147 --- /dev/null +++ b/tests/test_validation_run_registered_scaling_sweep.py @@ -0,0 +1,1348 @@ +from __future__ import annotations + +import json +import subprocess +from copy import deepcopy +from pathlib import Path + +import pytest + +from validation import register_scaling_sweep, run_registered_scaling_sweep + +SPEC_PATH = Path(__file__).parents[1] / "validation" / "scaling_sweep_v1.json" +RUNTIME = { + "dolfinx": "0.10.0", + "mpi4py": "4.1.1", + "mpi_library": "MPICH 4.3.1", + "petsc": "3.24.0", + "petsc_scalar_type": "complex128", +} + + +def _registration() -> dict: + encoded = SPEC_PATH.read_bytes() + return register_scaling_sweep.build_registration( + json.loads(encoded), + encoded, + source={"commit": "a" * 40, "dirty": False}, + project_image_kind="local_image_id", + project_image_identity="sha256:" + "b" * 64, + base_image_digest="sha256:" + "c" * 64, + base_runtime_metadata=RUNTIME, + ) + + +def _fem_payload(registration: dict, entry: dict) -> dict: + passed_gate = {"status": "PASSED", "passed": True} + command = entry["command"][4:] + expected_dofs = int(command[command.index("--expected-global-dofs") + 1]) + frequency = float(command[command.index("--frequencies-hz") + 1]) + fine_degree = int(command[command.index("--degree") + 1]) + coarse_degree = int(command[command.index("--p-multigrid-coarse-degree") + 1]) + subdivisions = int(command[command.index("--subdivisions") + 1]) + maximum_iterations = int(command[command.index("--maximum-iterations") + 1]) + maximum_residual = float( + command[command.index("--maximum-true-relative-residual") + 1] + ) + restart = int(command[command.index("--gmres-restart") + 1]) + overlap = int(command[command.index("--asm-overlap") + 1]) + shift = float(command[command.index("--preconditioner-absorption-shift") + 1]) + mpi_ranks = entry["resource_contract"]["mpi_ranks"] + coarse_dofs = max(1, expected_dofs // 10) + petsc_options = { + "ksp_gmres_restart": restart, + "mg_coarse_ksp_type": "preonly", + "mg_coarse_pc_factor_mat_solver_type": "mumps", + "mg_coarse_pc_type": "lu", + "mg_levels_1_ksp_max_it": 1, + "mg_levels_1_ksp_type": "richardson", + "mg_levels_1_pc_asm_overlap": overlap, + "mg_levels_1_pc_type": "asm", + "mg_levels_1_sub_ksp_type": "preonly", + "mg_levels_1_sub_pc_factor_mat_solver_type": "mumps", + "mg_levels_1_sub_pc_type": "lu", + } + ranks = list(range(mpi_ranks)) + top = { + "ksp_type": "fgmres", + "pc_type": "mg", + "factor_solver_type": None, + "options_prefix": "scatter3d_0_", + "instances": mpi_ranks, + "mpi_ranks": ranks, + "maximum_iterations": maximum_iterations, + "norm_type": "unpreconditioned", + "path": "top", + } + fine = { + "ksp_type": "richardson", + "pc_type": "asm", + "factor_solver_type": None, + "options_prefix": "scatter3d_0_mg_levels_1_", + "instances": mpi_ranks, + "mpi_ranks": ranks, + "maximum_iterations": 1, + "norm_type": "none", + "path": "mg.fine", + } + subdomain = { + "ksp_type": "preonly", + "pc_type": "lu", + "factor_solver_type": "mumps", + "options_prefix": "scatter3d_0_mg_levels_1_sub_", + "instances": mpi_ranks, + "mpi_ranks": ranks, + "maximum_iterations": 10_000, + "norm_type": "none", + "path": "mg.fine.asm.subdomains", + } + coarse = { + "ksp_type": "preonly", + "pc_type": "lu", + "factor_solver_type": "mumps", + "options_prefix": "scatter3d_0_mg_coarse_", + "instances": mpi_ranks, + "mpi_ranks": ranks, + "maximum_iterations": 10_000, + "norm_type": "none", + "path": "mg.coarse", + } + effective = { + "top_level": top, + "preconditioning_side": "right", + "maximum_iterations": maximum_iterations, + "relative_tolerance": 1.0e-8, + "absolute_tolerance": 1.0e-12, + "pc_uses_amat": False, + "mg_levels": 2, + "mg_type": "multiplicative", + "mg_cycle_type": "v", + "mg_galerkin": "none", + "mg_fine_smoother": fine, + "mg_fine_asm_type": "restrict", + "mg_fine_asm_overlap": overlap, + "mg_fine_asm_subdomain_solvers": [subdomain], + "mg_coarse_solver": coarse, + "petsc_view_ascii": ( + "type: fgmres\nright preconditioning\ntype: mg\nlevels=2\n" + "Not using Galerkin\ntype: richardson\ntype: asm\n" + "type: preonly\ntype: lu\n" + "package used to perform factorization: mumps\n" + ), + } + transfer = { + "direction": "coarse_to_fine", + "rows": expected_dofs, + "columns": coarse_dofs, + "nonzeros": expected_dofs, + "constrained_fine_rows": 1, + "constrained_coarse_columns": 1, + "maximum_imaginary_abs": 0.0, + "assembly_seconds": 0.01, + "memory_bytes_sum": None, + } + port_solves = [ + { + "port_name": name, + "iterations": 10, + "converged_reason": 2, + "true_relative_residual": 1.0e-9, + "true_residual_norm": 1.0e-10, + "reported_residual_history": [1.0, 1.0e-9], + "solve_seconds": 0.1, + } + for name in ("left", "right") + ] + expected_counts = { + "matrix_assemblies": 1, + "preconditioner_matrix_assemblies": 0 if shift == 0.0 else 1, + "coarse_preconditioner_matrix_assemblies": 1, + "transfer_operator_assemblies": 1, + "operator_setups": 1, + "global_numeric_factorizations": 0, + "coarse_global_factorizations": 1, + "rhs_solves": 2, + } + gates = { + name: dict(passed_gate) + for name in ( + "convergence_and_true_residual", + "assembly_setup_rhs_counts", + "p_multigrid_structure", + "expected_global_dofs", + "release_comparison_provenance", + ) + } + gates["expected_global_dofs"].update( + expected=expected_dofs, observed=expected_dofs + ) + gates["convergence_and_true_residual"].update( + maximum_true_relative_residual=maximum_residual + ) + gates["assembly_setup_rhs_counts"].update( + expected=deepcopy(expected_counts), observed=deepcopy(expected_counts) + ) + gates["p_multigrid_structure"].update( + requested_coarse_degree=coarse_degree + ) + diagnostic = { + "frequency_hz": frequency, + "global_complex_dofs": expected_dofs, + "fine_degree": fine_degree, + "coarse_degree": coarse_degree, + "coarse_global_complex_dofs": coarse_dofs, + "matrix_nonzeros": expected_dofs * 10, + "preconditioner_matrix_nonzeros": expected_dofs * 10, + "coarse_preconditioner_matrix_nonzeros": coarse_dofs * 10, + "matrix_memory_bytes_sum": None, + "preconditioner_matrix_memory_bytes_sum": None, + "coarse_preconditioner_matrix_memory_bytes_sum": None, + "rank_peak_rss_bytes_max": 1_000_000, + "rank_peak_rss_bytes_sum": 2_000_000, + "assembly_seconds": 0.1, + "preconditioner_assembly_seconds": 0.1, + "coarse_preconditioner_assembly_seconds": 0.01, + "setup_seconds": 0.2, + "preconditioner_absorption_shift": shift, + "preconditioner_operator_is_physical": shift == 0.0, + "p_multigrid_operator_checks_passed": True, + "transfer_operator": transfer, + "solver_hierarchy": { + "requested": { + "ksp_type": "fgmres", + "pc_type": "mg", + "preconditioning_side": "right", + "petsc_options": list(petsc_options.items()), + }, + "effective": effective, + }, + "port_solves": port_solves, + } + requested_solver = { + "solver": "iterative", + "iterative_hierarchy": "p-multigrid", + "ksp_type": "fgmres", + "pc_type": "mg", + "preconditioning_side": "right", + "maximum_iterations": maximum_iterations, + "p_multigrid_coarse_degree": coarse_degree, + "preconditioner_absorption_shift": shift, + "petsc_options": petsc_options, + } + preconditioner_metrics = { + "frequency_hz": frequency, + "absorption_shift": shift, + "operator_is_physical": shift == 0.0, + "matrix_nonzeros": diagnostic["preconditioner_matrix_nonzeros"], + "matrix_memory_bytes_sum": None, + "assembly_seconds": diagnostic["preconditioner_assembly_seconds"], + "coarse": { + "degree": coarse_degree, + "global_complex_dofs": coarse_dofs, + "matrix_nonzeros": diagnostic[ + "coarse_preconditioner_matrix_nonzeros" + ], + "matrix_memory_bytes_sum": None, + "assembly_seconds": diagnostic[ + "coarse_preconditioner_assembly_seconds" + ], + }, + "transfer_operator": transfer, + "p_multigrid_operator_checks_passed": True, + } + return { + "schema": "scatter3d.validation.fem_smoke/v2", + "status": "PASSED", + "passed": True, + "source": { + **registration["source"], + "provenance": "environment", + }, + "command": {"argv": command, "provenance": "process"}, + "physical_problem": deepcopy(entry["physical_problem"]), + "images": { + "project_image": { + "digest": None, + "local_image_id": registration["images"]["project_image"][ + "identity" + ], + }, + "base_image": { + "digest": registration["images"]["base_image"]["identity"], + "local_image_id": None, + }, + }, + "mpi_size": entry["resource_contract"]["mpi_ranks"], + "solver": "iterative", + "iterative_hierarchy": "p-multigrid", + "degree": fine_degree, + "subdivisions": subdivisions, + "frequencies_hz": [frequency], + "scalar_type": "", + "solver_configuration": { + "requested": requested_solver, + "effective_by_frequency": [ + {"frequency_hz": frequency, "hierarchy": effective} + ], + }, + "preconditioner": { + "requested_absorption_shift": entry[ + "preconditioner_absorption_shift" + ], + "requested_hierarchy": "p-multigrid", + "requested_coarse_degree": coarse_degree, + "per_frequency": [preconditioner_metrics], + }, + "cgroup_memory": { + "version": "v2", + "peak_bytes": 1_000_000, + "swap_limit_bytes": 0, + "limit_bytes": entry["resource_contract"][ + "cgroup_memory_limit_bytes" + ] + }, + "runtime": { + "dolfinx_version": "0.10.0", + "mpi4py_version": "4.1.1", + "mpi_library_version": "MPICH 4.3.1", + "petsc_version": [3, 24, 0], + "petsc_scalar_type": "", + }, + **expected_counts, + "numeric_factorizations": expected_counts["global_numeric_factorizations"], + "frequency_diagnostics": [diagnostic], + "gates": gates, + } + + +def _cgroup_kwargs(entry: dict, path: Path) -> dict: + limit = entry["resource_contract"]["cgroup_memory_limit_bytes"] + return { + "cgroup_resolver": lambda pid: path if pid > 0 else None, + "cgroup_reader": lambda observed_path: { + "version": "v2-host", + "path": str(observed_path), + "peak_bytes": 1_000_000, + "limit_bytes": limit, + "swap_limit_bytes": 0, + "events": {"oom": 0, "oom_kill": 0, "max": 0}, + }, + "cgroup_populated": lambda observed_path: observed_path == path, + } + + +def test_image_inspection_requires_registered_ids_and_labels() -> None: + registration = _registration() + image = { + "Id": registration["images"]["project_image"]["identity"], + "RepoDigests": [], + "Config": { + "Labels": { + "org.opencontainers.image.revision": registration["source"][ + "commit" + ], + "org.opencontainers.image.base.digest": registration["images"][ + "base_image" + ]["identity"], + }, + "Env": [ + f"SCATTER3D_GIT_COMMIT={registration['source']['commit']}", + "SCATTER3D_GIT_DIRTY=false", + ], + }, + } + assert ( + run_registered_scaling_sweep.validate_image_inspection( + registration, image + ) + == image["Id"] + ) + image["Config"]["Labels"]["org.opencontainers.image.revision"] = "d" * 40 + with pytest.raises(ValueError, match="revision"): + run_registered_scaling_sweep.validate_image_inspection( + registration, image + ) + image["Config"]["Labels"]["org.opencontainers.image.revision"] = registration[ + "source" + ]["commit"] + image["Config"]["Labels"]["org.opencontainers.image.base.digest"] = ( + "sha256:" + "d" * 64 + ) + with pytest.raises(ValueError, match="base digest"): + run_registered_scaling_sweep.validate_image_inspection(registration, image) + image["Config"]["Labels"]["org.opencontainers.image.base.digest"] = registration[ + "images" + ]["base_image"]["identity"] + image["Config"]["Env"] = [ + f"SCATTER3D_GIT_COMMIT={registration['source']['commit']}", + "SCATTER3D_GIT_DIRTY=true", + ] + with pytest.raises(ValueError, match="source environment"): + run_registered_scaling_sweep.validate_image_inspection(registration, image) + + +def test_docker_create_enforces_registered_memory_image_and_output_mount( + tmp_path: Path, +) -> None: + registration = _registration() + entry = registration["entries"][0] + command = run_registered_scaling_sweep.build_docker_create_command( + registration, + entry, + image_reference=registration["images"]["project_image"]["identity"], + host_run_directory=tmp_path, + ) + limit = str(entry["resource_contract"]["cgroup_memory_limit_bytes"]) + assert command[command.index("--memory") + 1] == limit + assert command[command.index("--memory-swap") + 1] == limit + assert "--ipc=host" in command + assert f"{tmp_path}:{entry['outputs']['container_directory']}" in command + assert command[command.index("--name") + 1].startswith("scatter3d-") + wrapper = command[command.index("sh") + 2] + assert ".solver-done" in wrapper + assert ".executor-collected" in wrapper + assert "exec \"$@\"" not in wrapper + assert command[-len(entry["command"]) :] == entry["command"] + + +def test_fem_artifact_validation_is_fail_closed() -> None: + registration = _registration() + entry = registration["entries"][0] + payload = _fem_payload(registration, entry) + run_registered_scaling_sweep.validate_fem_smoke_artifact( + registration, entry, payload + ) + payload["gates"]["expected_global_dofs"] = { + "status": "FAILED", + "passed": False, + } + with pytest.raises(ValueError, match=r"global DoF|gate"): + run_registered_scaling_sweep.validate_fem_smoke_artifact( + registration, entry, payload + ) + + +@pytest.mark.parametrize( + "tamper", + ( + "schema", + "solver_configuration", + "port_solves", + "true_relative_residual", + "negative_residual", + "numeric_nonfinite_residual", + "string_nonfinite_residual_norm", + "string_nonfinite_history", + "over_cap_iterations", + "petsc_view_ascii", + "hierarchy_ranks", + "hierarchy_prefix", + "rhs_solves", + "operator_identity", + "setup_seconds", + ), +) +def test_fem_artifact_rejects_missing_or_tampered_underlying_evidence( + tamper: str, +) -> None: + registration = _registration() + entry = registration["entries"][0] + payload = _fem_payload(registration, entry) + diagnostic = payload["frequency_diagnostics"][0] + if tamper == "schema": + payload.pop("schema") + elif tamper == "solver_configuration": + payload.pop("solver_configuration") + elif tamper == "port_solves": + diagnostic.pop("port_solves") + elif tamper == "true_relative_residual": + diagnostic["port_solves"][0].pop("true_relative_residual") + elif tamper == "negative_residual": + diagnostic["port_solves"][0]["true_relative_residual"] = -1.0 + elif tamper == "numeric_nonfinite_residual": + diagnostic["port_solves"][0]["true_relative_residual"] = float("-inf") + elif tamper == "string_nonfinite_residual_norm": + diagnostic["port_solves"][0]["true_residual_norm"] = "NaN" + elif tamper == "string_nonfinite_history": + diagnostic["port_solves"][0]["reported_residual_history"] = [ + 1.0, + "+Infinity", + ] + elif tamper == "over_cap_iterations": + diagnostic["port_solves"][0]["iterations"] = 1_001 + elif tamper == "petsc_view_ascii": + diagnostic["solver_hierarchy"]["effective"].pop("petsc_view_ascii") + elif tamper == "hierarchy_ranks": + diagnostic["solver_hierarchy"]["effective"]["top_level"][ + "mpi_ranks" + ] = [999] + elif tamper == "hierarchy_prefix": + diagnostic["solver_hierarchy"]["effective"]["mg_coarse_solver"][ + "options_prefix" + ] = "fake_" + elif tamper == "rhs_solves": + payload.pop("rhs_solves") + elif tamper == "operator_identity": + diagnostic["preconditioner_operator_is_physical"] = not diagnostic[ + "preconditioner_operator_is_physical" + ] + elif tamper == "setup_seconds": + diagnostic.pop("setup_seconds") + with pytest.raises(ValueError): + run_registered_scaling_sweep.validate_fem_smoke_artifact( + registration, entry, payload + ) + + +def test_failed_fem_artifact_remains_valid_diagnostic_evidence() -> None: + registration = _registration() + entry = registration["entries"][0] + payload = _fem_payload(registration, entry) + payload["status"] = "FAILED" + payload["passed"] = False + payload["gates"]["convergence_and_true_residual"] = { + "status": "FAILED", + "passed": False, + "maximum_true_relative_residual": 1.0e-7, + "reason": "iteration cap", + } + payload["frequency_diagnostics"][0]["port_solves"][0][ + "converged_reason" + ] = -3 + payload["frequency_diagnostics"][0]["port_solves"][0][ + "true_relative_residual" + ] = 1.0e-3 + failed = run_registered_scaling_sweep.validate_fem_smoke_artifact( + registration, entry, payload + ) + assert failed == ("convergence_and_true_residual",) + + +def test_failed_exact_dof_gate_remains_valid_diagnostic_evidence() -> None: + registration = _registration() + entry = registration["entries"][0] + payload = _fem_payload(registration, entry) + expected = payload["gates"]["expected_global_dofs"]["expected"] + observed = expected + 1 + payload["status"] = "FAILED" + payload["passed"] = False + payload["execution_phase"] = "preflight" + for name in ( + "convergence_and_true_residual", + "assembly_setup_rhs_counts", + "p_multigrid_structure", + ): + payload["gates"][name] = { + "status": "NOT RUN", + "passed": None, + "reason": "exact global DoF preflight FAILED before solve", + } + payload["gates"]["expected_global_dofs"] = { + "status": "FAILED", + "passed": False, + "expected": expected, + "observed": observed, + } + payload["frequency_diagnostics"] = [ + {"frequency_hz": 100_000_000.0, "global_complex_dofs": observed} + ] + for name in ( + "solver_configuration", + "matrix_assemblies", + "preconditioner_matrix_assemblies", + "coarse_preconditioner_matrix_assemblies", + "transfer_operator_assemblies", + "operator_setups", + "global_numeric_factorizations", + "coarse_global_factorizations", + "numeric_factorizations", + "rhs_solves", + ): + payload.pop(name) + payload["preconditioner"].pop("per_frequency") + assert run_registered_scaling_sweep.validate_fem_smoke_artifact( + registration, entry, payload + ) == ("expected_global_dofs",) + + inconsistent = deepcopy(payload) + inconsistent["gates"]["expected_global_dofs"]["observed"] = expected + inconsistent["frequency_diagnostics"][0]["global_complex_dofs"] = expected + with pytest.raises(ValueError, match="DoF failure"): + run_registered_scaling_sweep.validate_fem_smoke_artifact( + registration, entry, inconsistent + ) + + completed = deepcopy(payload) + completed["rhs_solves"] = 2 + completed["frequency_diagnostics"][0]["port_solves"] = [] + with pytest.raises(ValueError, match="preflight"): + run_registered_scaling_sweep.validate_fem_smoke_artifact( + registration, entry, completed + ) + + +def test_fem_artifact_rejects_physical_problem_tampering() -> None: + registration = _registration() + entry = registration["entries"][0] + payload = _fem_payload(registration, entry) + payload["physical_problem"]["definition"]["frequencies_hz"] = [200_000_000.0] + with pytest.raises(ValueError, match="physical problem"): + run_registered_scaling_sweep.validate_fem_smoke_artifact( + registration, entry, payload + ) + + +def test_non_preflight_registered_artifact_rejects_not_run_outcome_gate() -> None: + registration = _registration() + entry = registration["entries"][0] + payload = _fem_payload(registration, entry) + payload["status"] = "FAILED" + payload["passed"] = False + payload["gates"]["convergence_and_true_residual"] = { + "status": "FAILED", + "passed": False, + } + payload["gates"]["p_multigrid_structure"] = { + "status": "NOT RUN", + "passed": None, + } + with pytest.raises(ValueError, match="gate"): + run_registered_scaling_sweep.validate_fem_smoke_artifact( + registration, entry, payload + ) + + +def test_executor_preserves_result_files_manifest_and_no_clobber( + tmp_path: Path, +) -> None: + registration = _registration() + registration["entries"] = registration["entries"][:1] + entry = registration["entries"][0] + host_root = tmp_path / registration["output_root"] + run_directory = host_root / entry["run_id"] + inspect_count = 0 + + def runner(command, **kwargs): + nonlocal inspect_count + del kwargs + if command[:2] == ["docker", "create"]: + return subprocess.CompletedProcess(command, 0, b"container-id\n", b"") + if command[:2] == ["docker", "start"]: + run_directory.joinpath(".executor-ready").touch() + run_directory.joinpath("fem-smoke.json").write_text( + json.dumps(_fem_payload(registration, entry)), encoding="utf-8" + ) + run_directory.joinpath(".solver-exit-code").write_text( + "0\n", encoding="ascii" + ) + run_directory.joinpath(".solver-done").touch() + return subprocess.CompletedProcess(command, 0, b"container-id\n", b"") + if command[:2] == ["docker", "wait"]: + return subprocess.CompletedProcess(command, 0, b"0\n", b"") + if command[:2] == ["docker", "logs"]: + return subprocess.CompletedProcess(command, 0, b"solver output\n", b"") + if command[:2] == ["docker", "inspect"]: + inspect_count += 1 + running = inspect_count == 1 + inspection = { + "State": { + "StartedAt": "2026-07-12T00:00:00Z", + "FinishedAt": ( + "0001-01-01T00:00:00Z" + if running + else "2026-07-12T00:01:00Z" + ), + "ExitCode": 0, + "Running": running, + "OOMKilled": False, + "Pid": 123 if running else 0, + }, + "HostConfig": { + "Memory": entry["resource_contract"][ + "cgroup_memory_limit_bytes" + ], + "MemorySwap": entry["resource_contract"][ + "cgroup_memory_limit_bytes" + ], + }, + } + return subprocess.CompletedProcess( + command, 0, json.dumps(inspection).encode(), b"" + ) + if command[:3] == ["docker", "rm", "--force"]: + return subprocess.CompletedProcess(command, 0, b"container-id\n", b"") + if command[:2] == ["docker", "ps"]: + return subprocess.CompletedProcess(command, 0, b"", b"") + raise AssertionError(command) + + summaries = run_registered_scaling_sweep.execute_registered_entries( + registration, + image_reference=registration["images"]["project_image"]["identity"], + output_parent=tmp_path, + runner=runner, + **_cgroup_kwargs(entry, tmp_path), + ) + assert summaries[0]["status"] == "PASSED" + assert json.loads((run_directory / "exit-code.json").read_text())["status"] == "PASSED" + assert (run_directory / "stdout.log").read_text() == "solver output\n" + manifest = (run_directory / "SHA256SUMS").read_text() + assert "fem-smoke.json" in manifest + assert "exit-code.json" in manifest + with pytest.raises(FileExistsError, match="refusing to reuse"): + run_registered_scaling_sweep.execute_registered_entries( + registration, + image_reference=registration["images"]["project_image"]["identity"], + output_parent=tmp_path, + runner=runner, + ) + + +def test_classification_distinguishes_blocked_from_failed() -> None: + assert run_registered_scaling_sweep.classify_run_result( + docker_return_code=None, + fem_payload=None, + launch_prevented=True, + )[0] == "BLOCKED" + assert run_registered_scaling_sweep.classify_run_result( + docker_return_code=137, + fem_payload=None, + launch_prevented=False, + )[0] == "FAILED" + + +@pytest.mark.parametrize("docker_return_code", (None, 0, 2, 137)) +def test_failed_fem_artifact_requires_exact_docker_exit_one( + docker_return_code: int | None, +) -> None: + status, passed, reason = run_registered_scaling_sweep.classify_run_result( + docker_return_code=docker_return_code, + fem_payload={"status": "FAILED", "passed": False}, + launch_prevented=False, + ) + assert (status, passed) == ("FAILED", False) + assert "inconsistent" in reason + assert run_registered_scaling_sweep.classify_run_result( + docker_return_code=1, + fem_payload={"status": "FAILED", "passed": False}, + launch_prevented=False, + )[:2] == ("FAILED", False) + + +def test_inconsistent_fem_docker_exit_is_preserved_and_stops_next_run( + tmp_path: Path, +) -> None: + registration = _registration() + first, second = registration["entries"][:2] + first_directory = tmp_path / registration["output_root"] / first["run_id"] + second_directory = tmp_path / registration["output_root"] / second["run_id"] + create_count = 0 + inspect_count = 0 + + def runner(command, **kwargs): + nonlocal create_count, inspect_count + del kwargs + if command[:2] == ["docker", "create"]: + create_count += 1 + return subprocess.CompletedProcess(command, 0, b"container-id\n", b"") + if command[:2] == ["docker", "start"]: + payload = _fem_payload(registration, first) + payload["status"] = "FAILED" + payload["passed"] = False + payload["gates"]["convergence_and_true_residual"] = { + "status": "FAILED", + "passed": False, + "maximum_true_relative_residual": 1.0e-7, + } + payload["frequency_diagnostics"][0]["port_solves"][0][ + "converged_reason" + ] = -3 + payload["frequency_diagnostics"][0]["port_solves"][0][ + "true_relative_residual" + ] = 1.0e-3 + first_directory.joinpath(".executor-ready").touch() + first_directory.joinpath("fem-smoke.json").write_text( + json.dumps(payload), encoding="utf-8" + ) + first_directory.joinpath(".solver-exit-code").write_text( + "0\n", encoding="ascii" + ) + first_directory.joinpath(".solver-done").touch() + return subprocess.CompletedProcess(command, 0, b"", b"") + if command[:2] == ["docker", "wait"]: + return subprocess.CompletedProcess(command, 0, b"0\n", b"") + if command[:2] == ["docker", "logs"]: + return subprocess.CompletedProcess(command, 0, b"", b"") + if command[:2] == ["docker", "inspect"]: + inspect_count += 1 + running = inspect_count == 1 + inspection = { + "State": { + "StartedAt": "2026-07-12T00:00:00Z", + "FinishedAt": ( + "0001-01-01T00:00:00Z" + if running + else "2026-07-12T00:01:00Z" + ), + "ExitCode": 0, + "Running": running, + "OOMKilled": False, + "Pid": 123 if running else 0, + }, + "HostConfig": { + "Memory": first["resource_contract"][ + "cgroup_memory_limit_bytes" + ], + "MemorySwap": first["resource_contract"][ + "cgroup_memory_limit_bytes" + ], + }, + } + return subprocess.CompletedProcess( + command, 0, json.dumps(inspection).encode(), b"" + ) + if command[:3] == ["docker", "rm", "--force"]: + return subprocess.CompletedProcess(command, 0, b"", b"") + if command[:2] == ["docker", "ps"]: + return subprocess.CompletedProcess(command, 0, b"", b"") + raise AssertionError(command) + + with pytest.raises(RuntimeError, match="inconsistent"): + run_registered_scaling_sweep.execute_registered_entries( + registration, + image_reference=registration["images"]["project_image"]["identity"], + output_parent=tmp_path, + runner=runner, + **_cgroup_kwargs(first, tmp_path), + ) + assert create_count == 1 + persisted = json.loads(first_directory.joinpath("exit-code.json").read_text()) + assert persisted["evidence_consistent"] is False + assert first_directory.joinpath("SHA256SUMS").is_file() + assert not second_directory.exists() + + +@pytest.mark.parametrize( + "relative", + ( + "/system.slice/docker-container.scope", + "/docker/container-id", + ), +) +def test_cgroup_path_resolution_supports_systemd_and_cgroupfs( + tmp_path: Path, relative: str +) -> None: + expected = tmp_path.joinpath(relative.lstrip("/")) + expected.mkdir(parents=True) + assert run_registered_scaling_sweep._resolve_cgroup_relative_path( + relative, tmp_path + ) == expected.resolve() + with pytest.raises(ValueError, match="traversal"): + run_registered_scaling_sweep._resolve_cgroup_relative_path( + "/docker/../escape", tmp_path + ) + + +def test_cgroup_v2_metrics_require_peak_limits_swap_and_events( + tmp_path: Path, +) -> None: + values = { + "memory.peak": "1234\n", + "memory.max": "5678\n", + "memory.swap.max": "0\n", + "memory.events": "low 0\nhigh 0\nmax 2\noom 1\noom_kill 1\n", + } + for name, value in values.items(): + tmp_path.joinpath(name).write_text(value, encoding="utf-8") + metrics = run_registered_scaling_sweep._read_cgroup_v2_metrics(tmp_path) + assert metrics["peak_bytes"] == 1234 + assert metrics["events"]["oom_kill"] == 1 + tmp_path.joinpath("cgroup.events").write_text( + "populated 1\nfrozen 0\n", encoding="utf-8" + ) + assert run_registered_scaling_sweep._cgroup_is_populated(tmp_path) is True + tmp_path.joinpath("memory.peak").write_text("max\n", encoding="utf-8") + with pytest.raises(ValueError, match=r"memory\.peak"): + run_registered_scaling_sweep._read_cgroup_v2_metrics(tmp_path) + + +def test_container_inspection_rejects_malformed_exit_code() -> None: + payload = { + "State": { + "Running": False, + "OOMKilled": False, + "ExitCode": None, + "Pid": 0, + "StartedAt": "2026-07-12T00:00:00Z", + "FinishedAt": "2026-07-12T00:01:00Z", + }, + "HostConfig": {"Memory": 1, "MemorySwap": 1}, + } + with pytest.raises(ValueError, match="ExitCode"): + run_registered_scaling_sweep._parse_container_inspection( + json.dumps(payload).encode() + ) + + +def test_executor_can_select_remaining_registered_run_after_interruption( + tmp_path: Path, +) -> None: + registration = _registration() + first, second = registration["entries"][:2] + existing = tmp_path / registration["output_root"] / first["run_id"] + existing.mkdir(parents=True) + + def blocked_runner(command, **kwargs): + del kwargs + if command[:2] == ["docker", "create"]: + return subprocess.CompletedProcess(command, 1, b"", b"capacity") + if command[:3] == ["docker", "rm", "--force"]: + raise AssertionError("ordinary create failure must not remove by name") + if command[:2] == ["docker", "ps"]: + return subprocess.CompletedProcess(command, 0, b"", b"") + raise AssertionError(command) + + summaries = run_registered_scaling_sweep.execute_registered_entries( + registration, + image_reference=registration["images"]["project_image"]["identity"], + output_parent=tmp_path, + runner=blocked_runner, + run_ids=[second["run_id"]], + ) + assert summaries[0]["run_id"] == second["run_id"] + assert summaries[0]["status"] == "BLOCKED" + assert summaries[0]["container_cleanup_attempted"] is False + + +def test_executor_stops_before_next_registered_run_after_blocked( + tmp_path: Path, +) -> None: + registration = _registration() + registration["entries"] = registration["entries"][:2] + first, second = registration["entries"] + first_directory = tmp_path / registration["output_root"] / first["run_id"] + second_directory = tmp_path / registration["output_root"] / second["run_id"] + create_count = 0 + + def blocked_runner(command, **kwargs): + nonlocal create_count + del kwargs + if command[:2] == ["docker", "create"]: + create_count += 1 + return subprocess.CompletedProcess(command, 1, b"", b"capacity") + if command[:3] == ["docker", "rm", "--force"]: + raise AssertionError("ordinary create failure must not remove by name") + if command[:2] == ["docker", "ps"]: + return subprocess.CompletedProcess(command, 0, b"", b"") + raise AssertionError(command) + + summaries = run_registered_scaling_sweep.execute_registered_entries( + registration, + image_reference=registration["images"]["project_image"]["identity"], + output_parent=tmp_path, + runner=blocked_runner, + ) + assert create_count == 1 + assert len(summaries) == 1 + assert summaries[0]["run_id"] == first["run_id"] + assert summaries[0]["status"] == "BLOCKED" + assert first_directory.joinpath("exit-code.json").is_file() + assert first_directory.joinpath("SHA256SUMS").is_file() + assert not second_directory.exists() + + +def test_docker_create_timeout_still_cleans_deterministic_container_name( + tmp_path: Path, +) -> None: + registration = _registration() + registration["entries"] = registration["entries"][:1] + + def runner(command, **kwargs): + del kwargs + if command[:2] == ["docker", "create"]: + raise subprocess.TimeoutExpired(command, 60) + if command[:3] == ["docker", "rm", "--force"]: + return subprocess.CompletedProcess(command, 1, b"", b"not found") + if command[:2] == ["docker", "ps"]: + return subprocess.CompletedProcess(command, 0, b"", b"") + raise AssertionError(command) + + result = run_registered_scaling_sweep.execute_registered_entries( + registration, + image_reference=registration["images"]["project_image"]["identity"], + output_parent=tmp_path, + runner=runner, + cleanup_poll_seconds=0.0, + cleanup_stable_checks=3, + cleanup_max_checks=8, + )[0] + assert result["status"] == "BLOCKED" + assert result["container_cleanup_attempted"] is True + assert result["container_cleanup_succeeded"] is True + assert result["container_absence_verified"] is True + + +def test_docker_create_timeout_removes_late_attempt_owned_container( + tmp_path: Path, +) -> None: + registration = _registration() + registration["entries"] = registration["entries"][:1] + ps_queries = 0 + removed_targets: list[str] = [] + late_removed = False + + def runner(command, **kwargs): + nonlocal ps_queries, late_removed + del kwargs + if command[:2] == ["docker", "create"]: + raise subprocess.TimeoutExpired(command, 60) + if command[:2] == ["docker", "ps"]: + cycle = ps_queries // 2 + ps_queries += 1 + body = b"late-container-id\n" if cycle == 3 and not late_removed else b"" + return subprocess.CompletedProcess(command, 0, body, b"") + if command[:3] == ["docker", "rm", "--force"]: + removed_targets.append(command[-1]) + late_removed = command[-1] == "late-container-id" + return subprocess.CompletedProcess(command, 0, b"", b"") + raise AssertionError(command) + + result = run_registered_scaling_sweep.execute_registered_entries( + registration, + image_reference=registration["images"]["project_image"]["identity"], + output_parent=tmp_path, + runner=runner, + cleanup_poll_seconds=0.0, + cleanup_stable_checks=3, + cleanup_max_checks=8, + )[0] + assert result["status"] == "BLOCKED" + assert removed_targets == ["late-container-id"] + assert result["container_cleanup_succeeded"] is True + assert result["container_absence_verified"] is True + + +def test_executor_persists_failed_evidence_for_malformed_inspect( + tmp_path: Path, +) -> None: + registration = _registration() + registration["entries"] = registration["entries"][:1] + entry = registration["entries"][0] + run_directory = tmp_path / registration["output_root"] / entry["run_id"] + + def runner(command, **kwargs): + del kwargs + if command[:2] == ["docker", "create"]: + return subprocess.CompletedProcess(command, 0, b"container-id\n", b"") + if command[:2] == ["docker", "start"]: + run_directory.joinpath(".executor-ready").touch() + return subprocess.CompletedProcess(command, 0, b"", b"") + if command[:2] == ["docker", "inspect"]: + malformed = { + "State": { + "Running": True, + "OOMKilled": False, + "ExitCode": None, + "Pid": 123, + "StartedAt": "2026-07-12T00:00:00Z", + "FinishedAt": "0001-01-01T00:00:00Z", + }, + "HostConfig": {}, + } + return subprocess.CompletedProcess( + command, 0, json.dumps(malformed).encode(), b"" + ) + if command[:2] == ["docker", "logs"]: + return subprocess.CompletedProcess(command, 0, b"", b"") + if command[:3] == ["docker", "rm", "--force"]: + return subprocess.CompletedProcess(command, 0, b"", b"") + if command[:2] == ["docker", "ps"]: + return subprocess.CompletedProcess(command, 0, b"", b"") + raise AssertionError(command) + + result = run_registered_scaling_sweep.execute_registered_entries( + registration, + image_reference=registration["images"]["project_image"]["identity"], + output_parent=tmp_path, + runner=runner, + )[0] + assert result["status"] == "FAILED" + assert "ExitCode" in result["reason"] + assert (run_directory / "exit-code.json").is_file() + + +def test_executor_rejects_unknown_or_empty_run_selection(tmp_path: Path) -> None: + registration = _registration() + common = { + "image_reference": registration["images"]["project_image"]["identity"], + "output_parent": tmp_path, + } + with pytest.raises(ValueError, match="unregistered run ids"): + run_registered_scaling_sweep.execute_registered_entries( + registration, run_ids=["not-registered"], **common + ) + with pytest.raises(ValueError, match="at least one"): + run_registered_scaling_sweep.execute_registered_entries( + registration, run_ids=[], **common + ) + with pytest.raises(ValueError, match="differs from registered"): + run_registered_scaling_sweep.execute_registered_entries( + registration, + timeout_seconds=1, + run_ids=[registration["entries"][0]["run_id"]], + **common, + ) + + +@pytest.mark.parametrize( + ("state_updates", "host_updates", "expected_reason"), + [ + ({"Running": True, "FinishedAt": "0001-01-01T00:00:00Z"}, {}, "finish"), + ({"OOMKilled": True, "ExitCode": 137}, {}, "OOMKilled"), + ({"ExitCode": 137}, {}, "exited 137"), + ({}, {"MemorySwap": 0}, "resource contract"), + ], +) +def test_executor_fails_closed_on_container_state_and_resources( + tmp_path: Path, + state_updates: dict, + host_updates: dict, + expected_reason: str, +) -> None: + registration = _registration() + registration["entries"] = registration["entries"][:1] + entry = registration["entries"][0] + run_directory = ( + tmp_path / registration["output_root"] / entry["run_id"] + ) + limit = entry["resource_contract"]["cgroup_memory_limit_bytes"] + state = { + "StartedAt": "2026-07-12T00:00:00Z", + "FinishedAt": "2026-07-12T00:01:00Z", + "ExitCode": 0, + "Running": False, + "OOMKilled": False, + **state_updates, + } + host_config = {"Memory": limit, "MemorySwap": limit, **host_updates} + inspect_count = 0 + + def runner(command, **kwargs): + nonlocal inspect_count + del kwargs + if command[:2] == ["docker", "create"]: + return subprocess.CompletedProcess(command, 0, b"container-id\n", b"") + if command[:2] == ["docker", "start"]: + run_directory.joinpath(".executor-ready").touch() + run_directory.joinpath("fem-smoke.json").write_text( + json.dumps(_fem_payload(registration, entry)), encoding="utf-8" + ) + run_directory.joinpath(".solver-exit-code").write_text( + f"{state['ExitCode']}\n", encoding="ascii" + ) + run_directory.joinpath(".solver-done").touch() + return subprocess.CompletedProcess(command, 0, b"", b"") + if command[:2] == ["docker", "wait"]: + return subprocess.CompletedProcess(command, 0, b"", b"") + if command[:2] == ["docker", "logs"]: + return subprocess.CompletedProcess(command, 0, b"", b"") + if command[:2] == ["docker", "inspect"]: + inspect_count += 1 + observed_state = ( + { + "StartedAt": "2026-07-12T00:00:00Z", + "FinishedAt": "0001-01-01T00:00:00Z", + "ExitCode": 0, + "Running": True, + "OOMKilled": False, + "Pid": 123, + } + if inspect_count == 1 + else {**state, "Pid": 0 if not state["Running"] else 123} + ) + body = json.dumps( + {"State": observed_state, "HostConfig": host_config} + ).encode() + return subprocess.CompletedProcess(command, 0, body, b"") + if command[:3] == ["docker", "rm", "--force"]: + return subprocess.CompletedProcess(command, 0, b"", b"") + if command[:2] == ["docker", "ps"]: + return subprocess.CompletedProcess(command, 0, b"", b"") + raise AssertionError(command) + + arguments = { + "image_reference": registration["images"]["project_image"]["identity"], + "output_parent": tmp_path, + "runner": runner, + **_cgroup_kwargs(entry, tmp_path), + } + if expected_reason == "resource contract": + result = run_registered_scaling_sweep.execute_registered_entries( + registration, **arguments + )[0] + else: + with pytest.raises(RuntimeError, match="inconsistent"): + run_registered_scaling_sweep.execute_registered_entries( + registration, **arguments + ) + result = json.loads(run_directory.joinpath("exit-code.json").read_text()) + assert result["evidence_consistent"] is False + assert result["status"] == "FAILED" + assert expected_reason in result["reason"] + + +def test_executor_rejects_positive_host_cgroup_oom_without_oom_kill( + tmp_path: Path, +) -> None: + registration = _registration() + registration["entries"] = registration["entries"][:2] + entry, second = registration["entries"] + run_directory = tmp_path / registration["output_root"] / entry["run_id"] + second_directory = tmp_path / registration["output_root"] / second["run_id"] + create_count = 0 + inspect_count = 0 + + def runner(command, **kwargs): + nonlocal create_count, inspect_count + del kwargs + if command[:2] == ["docker", "create"]: + create_count += 1 + return subprocess.CompletedProcess(command, 0, b"container-id\n", b"") + if command[:2] == ["docker", "start"]: + run_directory.joinpath(".executor-ready").touch() + run_directory.joinpath("fem-smoke.json").write_text( + json.dumps(_fem_payload(registration, entry)), encoding="utf-8" + ) + run_directory.joinpath(".solver-exit-code").write_text( + "0\n", encoding="ascii" + ) + run_directory.joinpath(".solver-done").touch() + return subprocess.CompletedProcess(command, 0, b"", b"") + if command[:2] == ["docker", "wait"]: + return subprocess.CompletedProcess(command, 0, b"0\n", b"") + if command[:2] == ["docker", "logs"]: + return subprocess.CompletedProcess(command, 0, b"", b"") + if command[:2] == ["docker", "inspect"]: + inspect_count += 1 + running = inspect_count == 1 + body = { + "State": { + "StartedAt": "2026-07-12T00:00:00Z", + "FinishedAt": ( + "0001-01-01T00:00:00Z" + if running + else "2026-07-12T00:01:00Z" + ), + "ExitCode": 0, + "Running": running, + "OOMKilled": False, + "Pid": 123 if running else 0, + }, + "HostConfig": { + "Memory": entry["resource_contract"][ + "cgroup_memory_limit_bytes" + ], + "MemorySwap": entry["resource_contract"][ + "cgroup_memory_limit_bytes" + ], + }, + } + return subprocess.CompletedProcess( + command, 0, json.dumps(body).encode(), b"" + ) + if command[:3] == ["docker", "rm", "--force"]: + return subprocess.CompletedProcess(command, 0, b"", b"") + if command[:2] == ["docker", "ps"]: + return subprocess.CompletedProcess(command, 0, b"", b"") + raise AssertionError(command) + + cgroup = _cgroup_kwargs(entry, tmp_path) + cgroup["cgroup_reader"] = lambda observed_path: { + "version": "v2-host", + "path": str(observed_path), + "peak_bytes": 1_000_000, + "limit_bytes": entry["resource_contract"]["cgroup_memory_limit_bytes"], + "swap_limit_bytes": 0, + "events": {"oom": 1, "oom_kill": 0, "max": 1}, + } + summaries = run_registered_scaling_sweep.execute_registered_entries( + registration, + image_reference=registration["images"]["project_image"]["identity"], + output_parent=tmp_path, + runner=runner, + **cgroup, + ) + result = summaries[0] + assert create_count == 1 + assert len(summaries) == 1 + assert not second_directory.exists() + assert result["status"] == "FAILED" + assert "OOM activity" in result["reason"] + + +def test_executor_timeout_and_cleanup_failure_are_failed(tmp_path: Path) -> None: + registration = _registration() + registration["entries"] = registration["entries"][:1] + entry = registration["entries"][0] + entry["resource_contract"]["wall_time_limit_seconds"] = 1 + run_directory = tmp_path / registration["output_root"] / entry["run_id"] + inspect_count = 0 + + def runner(command, **kwargs): + nonlocal inspect_count + del kwargs + if command[:2] == ["docker", "create"]: + return subprocess.CompletedProcess(command, 0, b"container-id\n", b"") + if command[:2] == ["docker", "start"]: + run_directory.joinpath(".executor-ready").touch() + return subprocess.CompletedProcess(command, 0, b"", b"") + if command[:2] == ["docker", "wait"]: + return subprocess.CompletedProcess(command, 0, b"137\n", b"") + if command[:2] == ["docker", "kill"]: + return subprocess.CompletedProcess(command, 0, b"container-id\n", b"") + if command[:2] == ["docker", "logs"]: + return subprocess.CompletedProcess(command, 0, b"", b"") + if command[:2] == ["docker", "inspect"]: + inspect_count += 1 + running = inspect_count == 1 + state = { + "State": { + "StartedAt": "2026-07-12T00:00:00Z", + "FinishedAt": ( + "0001-01-01T00:00:00Z" + if running + else "2026-07-12T00:01:00Z" + ), + "ExitCode": 0 if running else 137, + "Running": running, + "OOMKilled": False, + "Pid": 123 if running else 0, + }, + "HostConfig": { + "Memory": registration["entries"][0]["resource_contract"][ + "cgroup_memory_limit_bytes" + ], + "MemorySwap": registration["entries"][0]["resource_contract"][ + "cgroup_memory_limit_bytes" + ], + }, + } + return subprocess.CompletedProcess( + command, 0, json.dumps(state).encode(), b"" + ) + if command[:3] == ["docker", "rm", "--force"]: + return subprocess.CompletedProcess(command, 1, b"", b"cleanup failed") + if command[:2] == ["docker", "ps"]: + return subprocess.CompletedProcess(command, 0, b"container-id\n", b"") + raise AssertionError(command) + + result = run_registered_scaling_sweep.execute_registered_entries( + registration, + image_reference=registration["images"]["project_image"]["identity"], + output_parent=tmp_path, + runner=runner, + timeout_seconds=1, + **_cgroup_kwargs(entry, tmp_path), + )[0] + assert result["status"] == "FAILED" + assert result["timed_out"] is True + assert result["container_cleanup_succeeded"] is False diff --git a/validation/fem_smoke.py b/validation/fem_smoke.py index 14e4e76..b634086 100644 --- a/validation/fem_smoke.py +++ b/validation/fem_smoke.py @@ -9,12 +9,456 @@ from __future__ import annotations import argparse -from dataclasses import asdict +import hashlib import json +import math +import os +import platform +import re +import subprocess +import sys +from collections.abc import Mapping, Sequence +from dataclasses import asdict from pathlib import Path +from typing import Any import numpy as np +SCHEMA = "scatter3d.validation.fem_smoke/v2" +_PROJECT_IMAGE_DIGEST_ENV = "SCATTER3D_PROJECT_IMAGE_DIGEST" +_PROJECT_IMAGE_ID_ENV = "SCATTER3D_PROJECT_IMAGE_ID" +_BASE_IMAGE_DIGEST_ENV = "SCATTER3D_BASE_IMAGE_DIGEST" +_GIT_COMMIT_ENV = "SCATTER3D_GIT_COMMIT" +_GIT_DIRTY_ENV = "SCATTER3D_GIT_DIRTY" +_FULL_GIT_COMMIT = re.compile(r"^[0-9a-fA-F]{40}$") +_IMAGE_DIGEST = re.compile(r"^sha256:[0-9a-fA-F]{64}$") + + +def _status(passed: bool) -> str: + return "PASSED" if passed else "FAILED" + + +def _gate(passed: bool, **details: Any) -> dict[str, Any]: + return {"status": _status(passed), "passed": bool(passed), **details} + + +def _environment_git_metadata(environment: Mapping[str, str]) -> dict[str, Any]: + commit_value = environment.get(_GIT_COMMIT_ENV, "").strip() + dirty_value = environment.get(_GIT_DIRTY_ENV, "").strip().lower() + if not commit_value and not dirty_value: + return {"commit": None, "dirty": None, "provenance": "unavailable"} + if not commit_value or not dirty_value: + raise ValueError( + f"{_GIT_COMMIT_ENV} and {_GIT_DIRTY_ENV} must be supplied together" + ) + if _FULL_GIT_COMMIT.fullmatch(commit_value) is None: + raise ValueError(f"{_GIT_COMMIT_ENV} must be a full 40-hex Git commit") + if dirty_value not in {"true", "false"}: + raise ValueError(f"{_GIT_DIRTY_ENV} must be the explicit boolean true or false") + return { + "commit": commit_value.lower(), + "dirty": dirty_value == "true", + "provenance": "environment", + "environment_variables": [_GIT_COMMIT_ENV, _GIT_DIRTY_ENV], + } + + +def _git_metadata( + repository: Path, environment: Mapping[str, str] | None = None +) -> dict[str, Any]: + """Return the exact source revision without guessing when Git is unavailable.""" + + environment = os.environ if environment is None else environment + + def run(*arguments: str) -> str: + completed = subprocess.run( + ["git", "-C", str(repository), *arguments], + check=True, + capture_output=True, + text=True, + timeout=10, + ) + return completed.stdout.strip() + + try: + commit = run("rev-parse", "HEAD") + dirty = bool(run("status", "--porcelain", "--untracked-files=normal")) + except (FileNotFoundError, subprocess.SubprocessError, OSError): + return _environment_git_metadata(environment) + if _FULL_GIT_COMMIT.fullmatch(commit) is None: + raise ValueError("git rev-parse HEAD did not return a full 40-hex commit") + return { + "commit": commit.lower(), + "dirty": dirty, + "provenance": "git_worktree", + } + + +def _image_digest_metadata(environment: Mapping[str, str]) -> dict[str, Any]: + """Read externally supplied image identities; never infer or fabricate them.""" + + def supplied(variable: str) -> str | None: + value = environment.get(variable) + if value is None or not value.strip(): + return None + normalized = value.strip().lower() + if _IMAGE_DIGEST.fullmatch(normalized) is None: + raise ValueError(f"{variable} must be sha256 followed by 64 hexadecimal digits") + return normalized + + project_digest = supplied(_PROJECT_IMAGE_DIGEST_ENV) + project_image_id = supplied(_PROJECT_IMAGE_ID_ENV) + if project_digest is not None and project_image_id is not None: + raise ValueError( + f"supply only one of {_PROJECT_IMAGE_DIGEST_ENV} and {_PROJECT_IMAGE_ID_ENV}" + ) + if project_digest is not None: + project = { + "digest": project_digest, + "local_image_id": None, + "provenance": "registry_or_oci_digest_environment", + "environment_variable": _PROJECT_IMAGE_DIGEST_ENV, + } + elif project_image_id is not None: + project = { + "digest": None, + "local_image_id": project_image_id, + "provenance": "docker_local_image_id_environment", + "environment_variable": _PROJECT_IMAGE_ID_ENV, + } + else: + project = { + "digest": None, + "local_image_id": None, + "provenance": "not_provided", + "environment_variable": None, + } + base_digest = supplied(_BASE_IMAGE_DIGEST_ENV) + base = { + "digest": base_digest, + "local_image_id": None, + "provenance": ( + "registry_or_oci_digest_environment" + if base_digest is not None + else "not_provided" + ), + "environment_variable": ( + _BASE_IMAGE_DIGEST_ENV if base_digest is not None else None + ), + } + + return { + "project_image": project, + "base_image": base, + } + + +def _read_cgroup_integer(path: Path) -> int | None: + try: + raw = path.read_text(encoding="utf-8").strip() + except (OSError, UnicodeError): + return None + if raw == "max": + return None + try: + value = int(raw) + except ValueError: + return None + return value if value >= 0 else None + + +def _cgroup_memory_metadata(root: Path = Path("/sys/fs/cgroup")) -> dict[str, Any]: + """Read cgroup v2 (or legacy v1) peak and limit without claiming availability.""" + + candidates = ( + ( + "v2", + root / "memory.peak", + root / "memory.max", + root / "memory.swap.max", + ), + ( + "v1", + root / "memory" / "memory.max_usage_in_bytes", + root / "memory" / "memory.limit_in_bytes", + None, + ), + ) + for version, peak_path, limit_path, swap_limit_path in candidates: + if peak_path.is_file() or limit_path.is_file(): + return { + "version": version, + "peak_bytes": _read_cgroup_integer(peak_path), + "limit_bytes": _read_cgroup_integer(limit_path), + "swap_limit_bytes": ( + _read_cgroup_integer(swap_limit_path) + if swap_limit_path is not None + else None + ), + "peak_source": str(peak_path) if peak_path.is_file() else None, + "limit_source": str(limit_path) if limit_path.is_file() else None, + "swap_limit_source": ( + str(swap_limit_path) + if swap_limit_path is not None and swap_limit_path.is_file() + else None + ), + "provenance": "cgroup_files", + } + return { + "version": None, + "peak_bytes": None, + "limit_bytes": None, + "swap_limit_bytes": None, + "peak_source": None, + "limit_source": None, + "swap_limit_source": None, + "provenance": "unavailable", + } + + +def _runtime_metadata(dolfinx: Any, PETSc: Any, MPI: Any) -> dict[str, Any]: + import mpi4py + + vendor = MPI.get_vendor() if hasattr(MPI, "get_vendor") else None + return { + "python_version": platform.python_version(), + "python_executable": sys.executable, + "platform": platform.platform(), + "numpy_version": np.__version__, + "dolfinx_version": dolfinx.__version__, + "petsc_version": list(PETSc.Sys.getVersion()), + "petsc_scalar_type": str(PETSc.ScalarType), + "mpi4py_version": mpi4py.__version__, + "mpi_vendor": list(vendor) if vendor is not None else None, + "mpi_library_version": " ".join(MPI.Get_library_version().split()), + "mpi_world_size": MPI.COMM_WORLD.size, + } + + +def _command_metadata(argv: Sequence[str]) -> dict[str, Any]: + return { + "argv": list(argv), + "working_directory": str(Path.cwd()), + "provenance": "process", + } + + +def _canonical_identity(definition: Mapping[str, Any]) -> dict[str, Any]: + encoded = json.dumps( + definition, + allow_nan=False, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + return { + "schema": "scatter3d.validation.physical_problem/v1", + "sha256": hashlib.sha256(encoded).hexdigest(), + "definition": dict(definition), + } + + +def _physical_problem_metadata( + *, + subdivisions: int, + frequencies_hz: Sequence[float], + problem_config: Any, + material_map: Any, + contract: Any, + port_definitions: Sequence[Any], +) -> dict[str, Any]: + """Identify the physical discretization independently of solver settings.""" + + definition = { + "mesh": { + "generator": "dolfinx.mesh.create_unit_cube", + "bounds_m": [[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]], + "subdivisions_xyz": [subdivisions, subdivisions, subdivisions], + "geometry_family": "linear-tetrahedral-unit-cube", + }, + "discretization": problem_config.canonical(), + "frequencies_hz": [float(value) for value in frequencies_hz], + "materials": material_map.canonical(), + "mesh_tags_and_boundaries": contract.canonical(), + "ports": [definition.canonical() for definition in port_definitions], + "operator_convention": { + "equation": "curl(mu_r^-1 curl(E)) - k0^2 epsilon_r_complex E", + "time_convention": "exp(-i*omega*t)", + "conductivity_embedding": "epsilon_r_complex=epsilon_r+i*sigma/(omega*epsilon_0)", + "pec": "tangential-electric-essential-boundary", + "ports": "matched-single-tem-impedance-boundary", + }, + } + return _canonical_identity(definition) + + +def _require_mapping(payload: Mapping[str, Any], key: str) -> Mapping[str, Any]: + value = payload.get(key) + if not isinstance(value, Mapping): + raise ValueError(f"comparison JSON is missing mapping {key!r}") + return value + + +def _validated_source(payload: Mapping[str, Any], label: str) -> str: + source = _require_mapping(payload, "source") + commit = source.get("commit") + if not isinstance(commit, str) or _FULL_GIT_COMMIT.fullmatch(commit) is None: + raise ValueError(f"{label} source commit must be a full 40-hex revision") + if source.get("dirty") is not False: + raise ValueError(f"{label} source must have explicit dirty=false") + return commit.lower() + + +def _validated_images(payload: Mapping[str, Any], label: str) -> dict[str, str]: + images = _require_mapping(payload, "images") + result: dict[str, str] = {} + project = images.get("project_image") + if not isinstance(project, Mapping): + raise ValueError(f"{label} is missing project_image identity") + project_digest = project.get("digest") + project_image_id = project.get("local_image_id") + if isinstance(project_digest, str) and project_image_id is None: + project_kind = "digest" + project_value = project_digest + elif isinstance(project_image_id, str) and project_digest is None: + project_kind = "local_image_id" + project_value = project_image_id + else: + raise ValueError(f"{label} project_image must have exactly one explicit identity") + if _IMAGE_DIGEST.fullmatch(project_value) is None: + raise ValueError(f"{label} project_image identity must be sha256:64hex") + result["project_image"] = f"{project_kind}:{project_value.lower()}" + + base = images.get("base_image") + if not isinstance(base, Mapping): + raise ValueError(f"{label} is missing base_image identity") + base_digest = base.get("digest") + if not isinstance(base_digest, str) or _IMAGE_DIGEST.fullmatch(base_digest) is None: + raise ValueError(f"{label} base_image digest must be an explicit sha256 digest") + if base.get("local_image_id") is not None: + raise ValueError(f"{label} base_image must be identified by its pinned digest") + result["base_image"] = f"digest:{base_digest.lower()}" + return result + + +def _release_provenance_gate(payload: Mapping[str, Any]) -> dict[str, Any]: + """Report release/comparison provenance without affecting a general solve gate.""" + + try: + _validated_source(payload, "artifact") + _validated_images(payload, "artifact") + if not _require_mapping(payload, "runtime"): + raise ValueError("runtime identity is empty") + _validated_physical_problem(payload, "artifact") + except ValueError as exc: + return _gate(False, scope="release_and_comparison_only", reason=str(exc)) + return _gate(True, scope="release_and_comparison_only") + + +def _validated_physical_problem(payload: Mapping[str, Any], label: str) -> dict[str, Any]: + identity = _require_mapping(payload, "physical_problem") + definition = identity.get("definition") + digest = identity.get("sha256") + if identity.get("schema") != "scatter3d.validation.physical_problem/v1": + raise ValueError(f"{label} physical_problem schema is missing or unsupported") + if not isinstance(definition, Mapping) or not isinstance(digest, str): + raise ValueError(f"{label} physical_problem identity is incomplete") + calculated = _canonical_identity(definition) + if digest.lower() != calculated["sha256"]: + raise ValueError(f"{label} physical_problem identity hash is invalid") + return calculated + + +def _matrix_identity(payload: Mapping[str, Any], label: str) -> tuple[tuple[float, int, int], ...]: + diagnostics = payload.get("frequency_diagnostics") + if not isinstance(diagnostics, list) or not diagnostics: + raise ValueError(f"{label} frequency diagnostics are missing") + identity: list[tuple[float, int, int]] = [] + for item in diagnostics: + if not isinstance(item, Mapping): + raise ValueError(f"{label} frequency diagnostic is malformed") + frequency = item.get("frequency_hz") + dofs = item.get("global_complex_dofs") + nonzeros = item.get("matrix_nonzeros") + if ( + not isinstance(frequency, int | float) + or isinstance(frequency, bool) + or not isinstance(dofs, int) + or isinstance(dofs, bool) + or not isinstance(nonzeros, int) + or isinstance(nonzeros, bool) + or dofs <= 0 + or nonzeros <= 0 + ): + raise ValueError(f"{label} physical A identity is incomplete") + identity.append((float(frequency), dofs, nonzeros)) + return tuple(identity) + + +def _validate_direct_comparison( + current: Mapping[str, Any], baseline: Mapping[str, Any] +) -> None: + """Fail closed unless direct and iterative memory runs are truly comparable.""" + + if baseline.get("solver") != "direct": + raise ValueError("comparison JSON must be from a direct run") + if baseline.get("status") != "PASSED" or baseline.get("passed") is not True: + raise ValueError("direct comparison overall status must be PASSED") + convergence = _require_mapping( + _require_mapping(baseline, "gates"), "convergence_and_true_residual" + ) + if convergence.get("status") != "PASSED" or convergence.get("passed") is not True: + raise ValueError("direct comparison convergence gate must be PASSED") + if current.get("mpi_size") != baseline.get("mpi_size") or not isinstance( + current.get("mpi_size"), int + ): + raise ValueError("direct comparison must use the same explicit MPI size") + if _validated_source(current, "iterative") != _validated_source( + baseline, "direct" + ): + raise ValueError("direct comparison must use the same source revision") + if _validated_images(current, "iterative") != _validated_images(baseline, "direct"): + raise ValueError("direct comparison must use identical project and base images") + current_runtime = _require_mapping(current, "runtime") + baseline_runtime = _require_mapping(baseline, "runtime") + if not current_runtime or current_runtime != baseline_runtime: + raise ValueError("direct comparison must use identical runtime identity") + if _validated_physical_problem(current, "iterative") != _validated_physical_problem( + baseline, "direct" + ): + raise ValueError("direct comparison must use the same physical_problem identity") + if _matrix_identity(current, "iterative") != _matrix_identity(baseline, "direct"): + raise ValueError( + "direct comparison must have identical frequencies, global DoFs, and physical A nonzeros" + ) + + +def _json_safe(value: Any) -> Any: + """Preserve nonfinite diagnostics explicitly without emitting invalid JSON numbers.""" + + if isinstance(value, np.generic): + return _json_safe(value.item()) + if isinstance(value, float) and not math.isfinite(value): + if math.isnan(value): + return "NaN" + return "+Infinity" if value > 0 else "-Infinity" + if isinstance(value, Mapping): + return {str(key): _json_safe(item) for key, item in value.items()} + if isinstance(value, list | tuple): + return [_json_safe(item) for item in value] + return value + + +def _write_payload( + payload: Mapping[str, Any], path: Path, *, overwrite: bool = False +) -> Path: + from scatter3d.pipeline import write_json_report + + destination = write_json_report(_json_safe(payload), path, overwrite=overwrite) + if os.name == "posix": + destination.chmod(0o644) + return destination + def _tagged_cube(comm, subdivisions: int): from dolfinx import mesh @@ -53,6 +497,188 @@ def _memory_high_water(payload: dict) -> int | None: return max(values) if values else None +def _validate_hierarchy_configuration( + *, + solver: str, + hierarchy: str, + fine_degree: int, + coarse_degree: int | None, + iterative_local_pc: str | None, +) -> bool: + """Validate solver hierarchy CLI values before importing the FEM runtime.""" + + if solver == "direct": + if hierarchy != "one-level-asm": + raise ValueError("--iterative-hierarchy requires --solver iterative") + if coarse_degree is not None: + raise ValueError( + "--p-multigrid-coarse-degree requires " + "--solver iterative --iterative-hierarchy p-multigrid" + ) + return False + if hierarchy == "one-level-asm": + if coarse_degree is not None: + raise ValueError( + "--p-multigrid-coarse-degree requires " + "--iterative-hierarchy p-multigrid" + ) + return False + if coarse_degree is None: + raise ValueError( + "--iterative-hierarchy p-multigrid requires " + "--p-multigrid-coarse-degree" + ) + if coarse_degree >= fine_degree: + raise ValueError("p-multigrid coarse degree must be lower than --degree") + if iterative_local_pc not in (None, "lu"): + raise ValueError( + "p-multigrid uses the verified fine ASM local LU/MUMPS path; " + "--iterative-local-pc must be omitted or set to lu" + ) + return True + + +def _expected_solver_counts( + *, + frequencies: int, + ports: int, + solver: str, + uses_p_multigrid: bool, + absorption_shift: float, +) -> dict[str, int]: + return { + "matrix_assemblies": frequencies, + "preconditioner_matrix_assemblies": ( + frequencies + if solver == "iterative" and absorption_shift != 0.0 + else 0 + ), + "coarse_preconditioner_matrix_assemblies": ( + frequencies if uses_p_multigrid else 0 + ), + "transfer_operator_assemblies": 1 if uses_p_multigrid else 0, + "operator_setups": frequencies, + "global_numeric_factorizations": frequencies if solver == "direct" else 0, + "coarse_global_factorizations": frequencies if uses_p_multigrid else 0, + "rhs_solves": frequencies * ports, + } + + +def _observed_solver_counts(result: Any) -> dict[str, int]: + return { + key: int(getattr(result, key)) + for key in ( + "matrix_assemblies", + "preconditioner_matrix_assemblies", + "coarse_preconditioner_matrix_assemblies", + "transfer_operator_assemblies", + "operator_setups", + "global_numeric_factorizations", + "coarse_global_factorizations", + "rhs_solves", + ) + } + + +def _p_multigrid_gate( + diagnostics: Sequence[Mapping[str, Any]], + *, + enabled: bool, + coarse_degree: int | None, + asm_overlap: int = 1, +) -> dict[str, Any]: + if not enabled: + return { + "status": "NOT RUN", + "passed": None, + "reason": "--iterative-hierarchy p-multigrid was not selected", + } + def effective_hierarchy_ok(item: Mapping[str, Any]) -> bool: + hierarchy = item.get("solver_hierarchy") + if not isinstance(hierarchy, Mapping): + return False + effective = hierarchy.get("effective") + if not isinstance(effective, Mapping): + return False + top = effective.get("top_level") + fine = effective.get("mg_fine_smoother") + coarse = effective.get("mg_coarse_solver") + subdomains = effective.get("mg_fine_asm_subdomain_solvers") + return ( + isinstance(top, Mapping) + and top.get("ksp_type") == "fgmres" + and top.get("pc_type") == "mg" + and effective.get("preconditioning_side") == "right" + and effective.get("pc_uses_amat") is False + and effective.get("mg_levels") == 2 + and effective.get("mg_type") == "multiplicative" + and effective.get("mg_cycle_type") == "v" + and effective.get("mg_galerkin") == "none" + and isinstance(fine, Mapping) + and fine.get("ksp_type") == "richardson" + and fine.get("pc_type") == "asm" + and fine.get("maximum_iterations") == 1 + and fine.get("norm_type") == "none" + and effective.get("mg_fine_asm_type") == "restrict" + and effective.get("mg_fine_asm_overlap") == asm_overlap + and isinstance(subdomains, Sequence) + and bool(subdomains) + and all( + isinstance(component, Mapping) + and component.get("ksp_type") == "preonly" + and component.get("pc_type") == "lu" + and component.get("factor_solver_type") == "mumps" + for component in subdomains + ) + and isinstance(coarse, Mapping) + and coarse.get("ksp_type") == "preonly" + and coarse.get("pc_type") == "lu" + and coarse.get("factor_solver_type") == "mumps" + ) + + passed = bool(diagnostics) and all( + item.get("coarse_degree") == coarse_degree + and isinstance(item.get("coarse_global_complex_dofs"), int) + and item["coarse_global_complex_dofs"] > 0 + and item["coarse_global_complex_dofs"] < item.get("global_complex_dofs", 0) + and isinstance(item.get("coarse_preconditioner_matrix_nonzeros"), int) + and item["coarse_preconditioner_matrix_nonzeros"] > 0 + and item.get("p_multigrid_operator_checks_passed") is True + and isinstance(item.get("transfer_operator"), Mapping) + and item["transfer_operator"].get("direction") == "coarse_to_fine" + and item["transfer_operator"].get("rows") == item.get("global_complex_dofs") + and item["transfer_operator"].get("columns") + == item.get("coarse_global_complex_dofs") + and isinstance(item["transfer_operator"].get("nonzeros"), int) + and item["transfer_operator"]["nonzeros"] > 0 + and isinstance( + item["transfer_operator"].get("constrained_fine_rows"), int + ) + and item["transfer_operator"]["constrained_fine_rows"] > 0 + and isinstance( + item["transfer_operator"].get("constrained_coarse_columns"), int + ) + and item["transfer_operator"]["constrained_coarse_columns"] > 0 + and item["transfer_operator"].get("maximum_imaginary_abs") == 0.0 + and effective_hierarchy_ok(item) + for item in diagnostics + ) + return _gate(passed, requested_coarse_degree=coarse_degree) + + +def _expected_global_dofs_gate( + observed: int, expected: int | None +) -> dict[str, Any]: + if expected is None: + return { + "status": "NOT RUN", + "passed": None, + "reason": "--expected-global-dofs was not supplied", + "observed": observed, + } + return _gate(observed == expected, expected=expected, observed=observed) + + def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--solver", choices=("direct", "iterative"), default="direct") @@ -60,19 +686,71 @@ def main() -> int: parser.add_argument("--subdivisions", type=int, default=3) parser.add_argument("--frequencies-hz", type=float, nargs="+", default=(1.0e8, 1.2e8)) parser.add_argument("--minimum-global-dofs", type=int, default=0) + parser.add_argument("--expected-global-dofs", type=int) parser.add_argument("--maximum-true-relative-residual", type=float, default=1.0e-7) + parser.add_argument("--maximum-iterations", type=int, default=1_000) + parser.add_argument("--gmres-restart", type=int, default=80) + parser.add_argument("--asm-overlap", type=int, default=1) + parser.add_argument( + "--iterative-hierarchy", + choices=("one-level-asm", "p-multigrid"), + default="one-level-asm", + ) + parser.add_argument( + "--p-multigrid-coarse-degree", + type=int, + choices=(1, 2), + help="explicit assembled low-order degree for the p-multigrid coarse level", + ) + parser.add_argument( + "--preconditioner-absorption-shift", + type=float, + default=0.0, + help="nonnegative absorption shift applied only to the iterative P matrix", + ) + parser.add_argument("--iterative-local-pc", choices=("ilu", "lu")) parser.add_argument("--compare-direct-json", type=Path) parser.add_argument("--maximum-memory-ratio", type=float, default=0.5) parser.add_argument("--output", type=Path, required=True) + parser.add_argument( + "--overwrite", + action="store_true", + help="explicitly replace an existing output artifact", + ) args = parser.parse_args() if args.subdivisions < 1: parser.error("subdivisions must be positive") + if args.minimum_global_dofs < 0: + parser.error("--minimum-global-dofs must be nonnegative") + if args.expected_global_dofs is not None and args.expected_global_dofs < 1: + parser.error("--expected-global-dofs must be positive") if any(value <= 0 for value in args.frequencies_hz) or any( - b <= a for a, b in zip(args.frequencies_hz, args.frequencies_hz[1:]) + b <= a for a, b in zip(args.frequencies_hz, args.frequencies_hz[1:], strict=False) ): parser.error("frequencies must be positive and strictly increasing") if args.compare_direct_json and args.solver != "iterative": parser.error("--compare-direct-json is meaningful only for the iterative run") + if args.maximum_iterations < 1 or args.gmres_restart < 1 or args.asm_overlap < 0: + parser.error("iteration/restart counts must be positive and overlap nonnegative") + if ( + not math.isfinite(args.preconditioner_absorption_shift) + or args.preconditioner_absorption_shift < 0 + ): + parser.error("--preconditioner-absorption-shift must be finite and nonnegative") + if args.solver == "direct" and args.preconditioner_absorption_shift != 0.0: + parser.error("--preconditioner-absorption-shift requires --solver iterative") + try: + uses_p_multigrid = _validate_hierarchy_configuration( + solver=args.solver, + hierarchy=args.iterative_hierarchy, + fine_degree=args.degree, + coarse_degree=args.p_multigrid_coarse_degree, + iterative_local_pc=args.iterative_local_pc, + ) + except ValueError as exc: + parser.error(str(exc)) + if args.output.exists() and not args.overwrite: + parser.error(f"refusing to overwrite existing artifact: {args.output.resolve()}") import dolfinx from dolfinx import fem @@ -85,7 +763,11 @@ def main() -> int: MaterialMap, MaxwellProblemConfig, ) - from scatter3d.fem.ports import PortDefinition, PortExcitation + from scatter3d.fem.ports import ( + MatchedTEMPortExcitation, + PortDefinition, + normalize_port_mode, + ) from scatter3d.fem.solver import MaxwellSweepSolver from scatter3d.fem.tags import ( BoundaryTagContract, @@ -98,25 +780,152 @@ def main() -> int: VolumeTagContract({"domain": 1}), BoundaryTagContract(ports={"left": 10, "right": 11}, pec_tags=(20,)), ) - solver_config = ( - LinearSolverConfig.direct() - if args.solver == "direct" - else LinearSolverConfig.iterative_maxwell() + if args.solver == "direct": + solver_config = LinearSolverConfig.direct() + elif uses_p_multigrid: + solver_config = LinearSolverConfig.iterative_p_multigrid( + coarse_degree=args.p_multigrid_coarse_degree, + maximum_iterations=args.maximum_iterations, + preconditioner_absorption_shift=args.preconditioner_absorption_shift, + error_if_not_converged=False, + petsc_options={ + "ksp_gmres_restart": args.gmres_restart, + "mg_levels_1_pc_asm_overlap": args.asm_overlap, + }, + ) + else: + iterative_local_pc = args.iterative_local_pc or "ilu" + local_options: dict[str, str | int] = { + "ksp_gmres_restart": args.gmres_restart, + "pc_asm_overlap": args.asm_overlap, + "sub_ksp_type": "preonly", + "sub_pc_type": iterative_local_pc, + } + if iterative_local_pc == "ilu": + local_options["sub_pc_factor_levels"] = 0 + else: + local_options["sub_pc_factor_mat_solver_type"] = "mumps" + solver_config = LinearSolverConfig.iterative_maxwell( + maximum_iterations=args.maximum_iterations, + preconditioner_absorption_shift=args.preconditioner_absorption_shift, + error_if_not_converged=False, + petsc_options=local_options, + ) + definitions = tuple( + PortDefinition( + name, + tag, + field_wave_impedance_ohm=200.0, + outgoing_propagation_index=1.0, + ) + for name, tag in (("left", 10), ("right", 11)) + ) + material_map = MaterialMap( + Material(2.0, conductivity_s_per_m=0.02, name="lossy") ) + problem_config = MaxwellProblemConfig(polynomial_degree=args.degree) solver = MaxwellSweepSolver.from_mesh( domain, cell_tags, facet_tags, contract, - MaterialMap(Material(2.0, conductivity_s_per_m=0.02, name="lossy")), - MaxwellProblemConfig(polynomial_degree=args.degree), + material_map, + problem_config, + matched_ports=definitions, solver_config=solver_config, initial_frequency_hz=args.frequencies_hz[0], ) + fine_map = solver.function_space.dofmap.index_map + preflight_global_dofs = int( + fine_map.size_global * solver.function_space.dofmap.index_map_bs + ) + if ( + args.expected_global_dofs is not None + and preflight_global_dofs != args.expected_global_dofs + ): + repository = Path(__file__).resolve().parents[1] + payload = { + "schema": SCHEMA, + "status": "FAILED", + "passed": False, + "execution_phase": "preflight", + "source": _git_metadata(repository), + "command": _command_metadata(sys.argv), + "images": _image_digest_metadata(os.environ), + "runtime": _runtime_metadata(dolfinx, PETSc, MPI), + "cgroup_memory": _cgroup_memory_metadata(), + "physical_problem": _physical_problem_metadata( + subdivisions=args.subdivisions, + frequencies_hz=args.frequencies_hz, + problem_config=problem_config, + material_map=material_map, + contract=contract, + port_definitions=definitions, + ), + "solver": args.solver, + "iterative_hierarchy": ( + args.iterative_hierarchy if args.solver == "iterative" else None + ), + "mpi_size": MPI.COMM_WORLD.size, + "preconditioner": { + "requested_absorption_shift": args.preconditioner_absorption_shift, + "requested_hierarchy": ( + args.iterative_hierarchy + if args.solver == "iterative" + else None + ), + "requested_coarse_degree": args.p_multigrid_coarse_degree, + }, + "frequency_diagnostics": [ + { + "frequency_hz": float(frequency), + "global_complex_dofs": preflight_global_dofs, + } + for frequency in args.frequencies_hz + ], + "gates": { + "convergence_and_true_residual": { + "status": "NOT RUN", + "passed": None, + "reason": "exact global DoF preflight FAILED before solve", + }, + "assembly_setup_rhs_counts": { + "status": "NOT RUN", + "passed": None, + "reason": "exact global DoF preflight FAILED before solve", + }, + "p_multigrid_structure": { + "status": "NOT RUN", + "passed": None, + "reason": "exact global DoF preflight FAILED before solve", + }, + "minimum_global_dofs": _gate( + preflight_global_dofs >= args.minimum_global_dofs, + requested=args.minimum_global_dofs, + observed=preflight_global_dofs, + ), + "expected_global_dofs": _expected_global_dofs_gate( + preflight_global_dofs, args.expected_global_dofs + ), + }, + } + payload["gates"]["release_comparison_provenance"] = ( + _release_provenance_gate(payload) + ) + if MPI.COMM_WORLD.rank == 0: + safe_payload = _json_safe(payload) + rendered = json.dumps( + safe_payload, indent=2, sort_keys=True, allow_nan=False + ) + "\n" + print(rendered, end="") + _write_payload(safe_payload, args.output, overwrite=args.overwrite) + return 1 excitations = [] - for name, tag in (("left", 10), ("right", 11)): - current = fem.Function(solver.function_space, name=f"J_{name}") - current.interpolate( + for definition in definitions: + raw_mode = fem.Function( + solver.function_space, name=f"mode_raw_{definition.name}" + ) + raw_mode.interpolate( lambda x: np.vstack( ( np.zeros(x.shape[1], dtype=PETSc.ScalarType), @@ -125,8 +934,8 @@ def main() -> int: ) ) ) - definition = PortDefinition(name, tag) - excitations.append(PortExcitation(definition, current)) + mode = normalize_port_mode(raw_mode, facet_tags, definition) + excitations.append(MatchedTEMPortExcitation(mode)) result = solver.solve( args.frequencies_hz, excitations, retain_solutions=False @@ -138,18 +947,102 @@ def main() -> int: for frequency in diagnostics for port in frequency["port_solves"] ) - count_ok = ( - result.matrix_assemblies == len(args.frequencies_hz) - and result.operator_setups == len(args.frequencies_hz) - and result.rhs_solves == len(args.frequencies_hz) * len(excitations) - and result.numeric_factorizations - == (len(args.frequencies_hz) if args.solver == "direct" else 0) + expected_counts = _expected_solver_counts( + frequencies=len(args.frequencies_hz), + ports=len(excitations), + solver=args.solver, + uses_p_multigrid=uses_p_multigrid, + absorption_shift=args.preconditioner_absorption_shift, + ) + observed_counts = _observed_solver_counts(result) + count_ok = observed_counts == expected_counts + p_multigrid_gate = _p_multigrid_gate( + diagnostics, + enabled=uses_p_multigrid, + coarse_degree=args.p_multigrid_coarse_degree, + asm_overlap=args.asm_overlap, ) + p_multigrid_ok = not uses_p_multigrid or p_multigrid_gate["passed"] is True global_dofs = diagnostics[0]["global_complex_dofs"] dof_gate = global_dofs >= args.minimum_global_dofs + expected_dof_gate = _expected_global_dofs_gate( + global_dofs, args.expected_global_dofs + ) + expected_dof_ok = ( + args.expected_global_dofs is None or expected_dof_gate["passed"] is True + ) + requested_solver = { + "solver": args.solver, + "iterative_hierarchy": ( + args.iterative_hierarchy if args.solver == "iterative" else None + ), + **solver_config.canonical(), + } + effective_solver = [ + { + "frequency_hz": item["frequency_hz"], + "hierarchy": item["solver_hierarchy"]["effective"], + } + for item in diagnostics + ] + preconditioner_metrics = [ + { + "frequency_hz": item["frequency_hz"], + "absorption_shift": item["preconditioner_absorption_shift"], + "operator_is_physical": item["preconditioner_operator_is_physical"], + "matrix_nonzeros": item["preconditioner_matrix_nonzeros"], + "matrix_memory_bytes_sum": item[ + "preconditioner_matrix_memory_bytes_sum" + ], + "assembly_seconds": item["preconditioner_assembly_seconds"], + "coarse": { + "degree": item["coarse_degree"], + "global_complex_dofs": item["coarse_global_complex_dofs"], + "matrix_nonzeros": item[ + "coarse_preconditioner_matrix_nonzeros" + ], + "matrix_memory_bytes_sum": item[ + "coarse_preconditioner_matrix_memory_bytes_sum" + ], + "assembly_seconds": item[ + "coarse_preconditioner_assembly_seconds" + ], + }, + "transfer_operator": item["transfer_operator"], + "p_multigrid_operator_checks_passed": item[ + "p_multigrid_operator_checks_passed" + ], + } + for item in diagnostics + ] + repository = Path(__file__).resolve().parents[1] payload = { - "schema": "scatter3d.validation.fem_smoke/v1", + "schema": SCHEMA, + "status": "FAILED", + "passed": False, + "source": _git_metadata(repository), + "command": _command_metadata(sys.argv), + "images": _image_digest_metadata(os.environ), + "runtime": _runtime_metadata(dolfinx, PETSc, MPI), + "cgroup_memory": _cgroup_memory_metadata(), + "physical_problem": _physical_problem_metadata( + subdivisions=args.subdivisions, + frequencies_hz=args.frequencies_hz, + problem_config=problem_config, + material_map=material_map, + contract=contract, + port_definitions=definitions, + ), "solver": args.solver, + "iterative_hierarchy": ( + args.iterative_hierarchy if args.solver == "iterative" else None + ), + # Retained as an explicit compatibility alias for v1 artifact readers. + "solver_config": solver_config.canonical(), + "solver_configuration": { + "requested": requested_solver, + "effective_by_frequency": effective_solver, + }, "degree": args.degree, "geometry_order": 1, "subdivisions": args.subdivisions, @@ -159,29 +1052,53 @@ def main() -> int: "petsc_version": PETSc.Sys.getVersion(), "scalar_type": str(PETSc.ScalarType), "matrix_assemblies": result.matrix_assemblies, + "preconditioner_matrix_assemblies": result.preconditioner_matrix_assemblies, + "coarse_preconditioner_matrix_assemblies": ( + result.coarse_preconditioner_matrix_assemblies + ), + "transfer_operator_assemblies": result.transfer_operator_assemblies, "operator_setups": result.operator_setups, - "numeric_factorizations": result.numeric_factorizations, + "global_numeric_factorizations": result.global_numeric_factorizations, + "coarse_global_factorizations": result.coarse_global_factorizations, + # Backward-compatible v1 alias. This never includes ASM-local factors. + "numeric_factorizations": result.global_numeric_factorizations, "rhs_solves": result.rhs_solves, + "preconditioner": { + "requested_absorption_shift": args.preconditioner_absorption_shift, + "requested_hierarchy": ( + args.iterative_hierarchy if args.solver == "iterative" else None + ), + "requested_coarse_degree": args.p_multigrid_coarse_degree, + "per_frequency": preconditioner_metrics, + }, "frequency_diagnostics": diagnostics, "gates": { - "convergence_and_true_residual": convergence_ok, - "assembly_setup_rhs_counts": count_ok, - "minimum_global_dofs_requested": args.minimum_global_dofs, - "minimum_global_dofs_passed": dof_gate, + "convergence_and_true_residual": _gate( + convergence_ok, + maximum_true_relative_residual=args.maximum_true_relative_residual, + ), + "assembly_setup_rhs_counts": _gate( + count_ok, + expected=expected_counts, + observed=observed_counts, + ), + "p_multigrid_structure": p_multigrid_gate, + "minimum_global_dofs": _gate( + dof_gate, + requested=args.minimum_global_dofs, + observed=global_dofs, + ), + "expected_global_dofs": expected_dof_gate, }, } + payload["gates"]["release_comparison_provenance"] = _release_provenance_gate( + payload + ) memory_ratio_ok = True if args.compare_direct_json: baseline = json.loads(args.compare_direct_json.read_text(encoding="utf-8")) - if baseline.get("solver") != "direct": - raise ValueError("comparison JSON must be from a direct run") - if ( - baseline.get("degree") != args.degree - or baseline.get("subdivisions") != args.subdivisions - or baseline.get("frequencies_hz") != list(args.frequencies_hz) - ): - raise ValueError("direct comparison must use the identical discrete problem") + _validate_direct_comparison(payload, baseline) current_rss = _memory_high_water(payload) baseline_rss = _memory_high_water(baseline) if current_rss is None or baseline_rss is None or baseline_rss <= 0: @@ -194,17 +1111,34 @@ def main() -> int: "direct_bytes": baseline_rss, "ratio": ratio, "maximum_ratio": args.maximum_memory_ratio, + "status": _status(memory_ratio_ok), "passed": memory_ratio_ok, "note": "Scheduler/job MaxRSS is preferred for a publication claim.", } + else: + payload["memory_comparison"] = { + "status": "NOT RUN", + "passed": None, + "reason": "--compare-direct-json was not supplied", + } - passed = convergence_ok and count_ok and dof_gate and memory_ratio_ok + passed = ( + convergence_ok + and count_ok + and p_multigrid_ok + and dof_gate + and expected_dof_ok + and memory_ratio_ok + ) + payload["status"] = _status(passed) payload["passed"] = passed if MPI.COMM_WORLD.rank == 0: - rendered = json.dumps(payload, indent=2, sort_keys=True) + "\n" + safe_payload = _json_safe(payload) + rendered = json.dumps( + safe_payload, indent=2, sort_keys=True, allow_nan=False + ) + "\n" print(rendered, end="") - args.output.parent.mkdir(parents=True, exist_ok=True) - args.output.write_text(rendered, encoding="utf-8") + _write_payload(safe_payload, args.output, overwrite=args.overwrite) return 0 if passed else 1 diff --git a/validation/manufactured_hcurl.py b/validation/manufactured_hcurl.py index b8338af..59b31fd 100644 --- a/validation/manufactured_hcurl.py +++ b/validation/manufactured_hcurl.py @@ -9,11 +9,12 @@ from __future__ import annotations import argparse -from dataclasses import asdict, dataclass import json +from collections.abc import Sequence +from dataclasses import asdict, dataclass +from itertools import pairwise from math import log from pathlib import Path -from typing import Sequence import numpy as np @@ -35,6 +36,7 @@ def _tag_cube(domain): tdim = domain.topology.dim fdim = tdim - 1 + domain.topology.create_connectivity(fdim, tdim) cell_count = domain.topology.index_map(tdim).size_local cell_indices = np.arange(cell_count, dtype=np.int32) cell_tags = mesh.meshtags( @@ -115,9 +117,11 @@ def solve_level(degree: int, subdivisions: int, frequency_hz: float) -> LevelRes pc.setType("lu") try: pc.setFactorSolverType("mumps") - except PETSc.Error: + except PETSc.Error as exc: if domain.comm.size > 1: - raise RuntimeError("parallel manufactured validation requires MUMPS") + raise RuntimeError( + "parallel manufactured validation requires MUMPS" + ) from exc ksp.setErrorIfNotConverged(True) ksp.solve(rhs, solution.x.petsc_vec) solution.x.scatter_forward() @@ -154,7 +158,7 @@ def solve_level(degree: int, subdivisions: int, frequency_hz: float) -> LevelRes def observed_orders(levels: Sequence[LevelResult]) -> list[float]: return [ log(coarse.hcurl_error / fine.hcurl_error) / log(coarse.h / fine.h) - for coarse, fine in zip(levels, levels[1:]) + for coarse, fine in pairwise(levels) ] @@ -171,7 +175,7 @@ def main() -> int: parser.error("degrees must be selected from 1, 2, and 3") if len(args.subdivisions) < 3 or any(value < 1 for value in args.subdivisions): parser.error("provide at least three positive subdivisions") - if any(b <= a for a, b in zip(args.subdivisions, args.subdivisions[1:])): + if any(b <= a for a, b in zip(args.subdivisions, args.subdivisions[1:], strict=False)): parser.error("subdivisions must be strictly increasing") import dolfinx @@ -188,7 +192,7 @@ def main() -> int: orders = observed_orders(levels) monotone = all( fine.hcurl_error < coarse.hcurl_error - for coarse, fine in zip(levels, levels[1:]) + for coarse, fine in pairwise(levels) ) residual_ok = all( level.converged_reason > 0 diff --git a/validation/register_scaling_sweep.py b/validation/register_scaling_sweep.py new file mode 100644 index 0000000..6a24e4a --- /dev/null +++ b/validation/register_scaling_sweep.py @@ -0,0 +1,457 @@ +#!/usr/bin/env python3 +"""Validate and write-once register the fixed Scatter3D scaling sweep.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +import re +import subprocess +import tempfile +from collections.abc import Mapping +from pathlib import Path +from typing import Any + +SPEC_SCHEMA = "scatter3d.validation.scaling_sweep/v1" +REGISTRATION_SCHEMA = "scatter3d.validation.scaling_sweep_registration/v1" +STATUS_VOCABULARY = ("PASSED", "FAILED", "NOT RUN", "BLOCKED") +_FULL_GIT_COMMIT = re.compile(r"^[0-9a-fA-F]{40}$") +_SHA256_IDENTITY = re.compile(r"^sha256:[0-9a-fA-F]{64}$") + +_EXPECTED_SPEC: dict[str, Any] = { + "schema": SPEC_SCHEMA, + "canonical_path": "validation/scaling_sweep_v1.json", + "status_vocabulary": list(STATUS_VOCABULARY), + "physical_problem": { + "fine_degree": 3, + "coarse_degree": 1, + "frequency_hz": 100_000_000.0, + "ports": ["left", "right"], + }, + "solver": { + "hierarchy": "p-multigrid", + "outer_ksp": "fgmres", + "preconditioning_side": "right", + "maximum_true_relative_residual": 1.0e-7, + "maximum_iterations": 1_000, + "asm_overlap": 1, + "fine_local_ksp": "preonly", + "fine_local_pc": "lu", + "fine_local_factor_solver": "mumps", + "coarse_ksp": "preonly", + "coarse_pc": "lu", + "coarse_factor_solver": "mumps", + }, + "preconditioner_absorption_shifts": [0.0, 0.25, 0.5, 1.0], + "cgroup_memory_limit_bytes": 28 * 1024**3, + "wall_time_limit_seconds": 10_800, + "rungs": [ + { + "id": "p3-n9-mpi4", + "subdivisions": 9, + "mpi_ranks": 4, + "gmres_restart": 80, + "expected_global_complex_dofs": 86_103, + }, + { + "id": "p3-n16-mpi8", + "subdivisions": 16, + "mpi_ranks": 8, + "gmres_restart": 100, + "expected_global_complex_dofs": 470_928, + }, + ], +} + + +def validate_scaling_sweep_spec(spec: Mapping[str, Any]) -> None: + """Fail closed unless the document is the exact registered v1 experiment.""" + + if dict(spec) != _EXPECTED_SPEC: + raise ValueError( + "scaling sweep specification differs from the immutable v1 contract" + ) + + +def _sha256_bytes(payload: bytes) -> str: + return hashlib.sha256(payload).hexdigest() + + +def _canonical_sha256(payload: Mapping[str, Any]) -> str: + encoded = json.dumps( + payload, + allow_nan=False, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + return _sha256_bytes(encoded) + + +def _validated_source(source: Mapping[str, Any]) -> dict[str, Any]: + commit = source.get("commit") + dirty = source.get("dirty") + if not isinstance(commit, str) or _FULL_GIT_COMMIT.fullmatch(commit) is None: + raise ValueError("source commit must be a full 40-hex Git revision") + if not isinstance(dirty, bool): + raise ValueError("source dirty state must be an explicit boolean") + if dirty: + raise ValueError("scaling sweep source dirty state must be exactly false") + return {"commit": commit.lower(), "dirty": False} + + +def _validated_image_identity(value: str, label: str) -> str: + normalized = value.strip().lower() + if _SHA256_IDENTITY.fullmatch(normalized) is None: + raise ValueError(f"{label} must be sha256 followed by 64 hexadecimal digits") + return normalized + + +def _validated_runtime_metadata(metadata: Mapping[str, Any]) -> dict[str, Any]: + normalized = dict(metadata) + if not normalized: + raise ValueError("base runtime metadata must be a nonempty JSON object") + try: + json.dumps(normalized, allow_nan=False, sort_keys=True) + except (TypeError, ValueError) as exc: + raise ValueError("base runtime metadata must be strict JSON") from exc + return normalized + + +def _git_source(repository: Path) -> dict[str, Any]: + def run(*arguments: str) -> str: + completed = subprocess.run( + ["git", "-C", str(repository), *arguments], + check=True, + capture_output=True, + text=True, + timeout=10, + ) + return completed.stdout.strip() + + try: + source = { + "commit": run("rev-parse", "HEAD"), + "dirty": bool( + run("status", "--porcelain", "--untracked-files=normal") + ), + } + except (FileNotFoundError, subprocess.SubprocessError, OSError) as exc: + raise ValueError(f"cannot establish exact Git source identity: {exc}") from exc + return _validated_source(source) + + +def _shift_label(shift: float) -> str: + return f"{shift:.2f}".replace(".", "p") + + +def _cli_float(value: float) -> str: + return str(float(value)) + + +def _expected_physical_problem( + *, subdivisions: int, frequency_hz: float, degree: int +) -> dict[str, Any]: + """Return the complete physical identity expected from fem_smoke.""" + + ports = [ + { + "name": name, + "facet_tag": tag, + "field_wave_impedance_ohm": [200.0, 0.0], + "outgoing_propagation_index": [1.0, 0.0], + "circuit_reference_impedance_ohm": 50.0, + "target_forward_power_w": 1.0, + "mode_model": "matched-single-tem", + "time_convention": "exp(-i*omega*t)", + } + for name, tag in (("left", 10), ("right", 11)) + ] + definition = { + "mesh": { + "generator": "dolfinx.mesh.create_unit_cube", + "bounds_m": [[0.0, 0.0, 0.0], [1.0, 1.0, 1.0]], + "subdivisions_xyz": [subdivisions, subdivisions, subdivisions], + "geometry_family": "linear-tetrahedral-unit-cube", + }, + "discretization": { + "polynomial_degree": degree, + "geometry_order": 1, + "quadrature_degree": max(2 * degree + 2, 6), + "time_convention": "exp(-i*omega*t)", + }, + "frequencies_hz": [float(frequency_hz)], + "materials": { + "default": { + "name": "lossy", + "relative_permittivity": [2.0, 0.0], + "relative_permeability": [1.0, 0.0], + "conductivity_s_per_m": 0.02, + }, + "regions": {}, + }, + "mesh_tags_and_boundaries": { + "volumes": {"volumes": {"domain": 1}, "pml_names": []}, + "boundaries": { + "ports": {"left": 10, "right": 11}, + "pec_tags": [20], + "observation_tags": [], + }, + }, + "ports": ports, + "operator_convention": { + "equation": "curl(mu_r^-1 curl(E)) - k0^2 epsilon_r_complex E", + "time_convention": "exp(-i*omega*t)", + "conductivity_embedding": ( + "epsilon_r_complex=epsilon_r+i*sigma/(omega*epsilon_0)" + ), + "pec": "tangential-electric-essential-boundary", + "ports": "matched-single-tem-impedance-boundary", + }, + } + return { + "schema": "scatter3d.validation.physical_problem/v1", + "sha256": _canonical_sha256(definition), + "definition": definition, + } + + +def _solver_command( + *, + spec: Mapping[str, Any], + rung: Mapping[str, Any], + shift: float, + output: str, +) -> list[str]: + physical = spec["physical_problem"] + solver = spec["solver"] + return [ + "mpirun", + "-n", + str(rung["mpi_ranks"]), + "python3", + "validation/fem_smoke.py", + "--solver", + "iterative", + "--iterative-hierarchy", + "p-multigrid", + "--degree", + str(physical["fine_degree"]), + "--p-multigrid-coarse-degree", + str(physical["coarse_degree"]), + "--subdivisions", + str(rung["subdivisions"]), + "--frequencies-hz", + _cli_float(physical["frequency_hz"]), + "--expected-global-dofs", + str(rung["expected_global_complex_dofs"]), + "--maximum-true-relative-residual", + _cli_float(solver["maximum_true_relative_residual"]), + "--maximum-iterations", + str(solver["maximum_iterations"]), + "--gmres-restart", + str(rung["gmres_restart"]), + "--asm-overlap", + str(solver["asm_overlap"]), + "--iterative-local-pc", + "lu", + "--preconditioner-absorption-shift", + _cli_float(shift), + "--output", + output, + ] + + +def build_registration( + spec: Mapping[str, Any], + spec_bytes: bytes, + *, + source: Mapping[str, Any], + project_image_kind: str, + project_image_identity: str, + base_image_digest: str, + base_runtime_metadata: Mapping[str, Any], +) -> dict[str, Any]: + """Expand the immutable specification into deterministic initial entries.""" + + validate_scaling_sweep_spec(spec) + if project_image_kind not in {"oci_digest", "local_image_id"}: + raise ValueError("project image kind must be oci_digest or local_image_id") + validated_source = _validated_source(source) + project_identity = _validated_image_identity( + project_image_identity, "project image identity" + ) + base_identity = _validated_image_identity(base_image_digest, "base image digest") + runtime_metadata = _validated_runtime_metadata(base_runtime_metadata) + runtime_metadata_sha256 = _canonical_sha256(runtime_metadata) + spec_sha256 = _sha256_bytes(spec_bytes) + images = { + "project_image": { + "kind": project_image_kind, + "identity": project_identity, + }, + "base_image": { + "kind": "oci_digest", + "identity": base_identity, + "runtime_metadata": runtime_metadata, + "runtime_metadata_sha256": runtime_metadata_sha256, + }, + } + registration_id = _canonical_sha256( + { + "specification_sha256": spec_sha256, + "source": validated_source, + "images": images, + } + ) + output_root = f"scaling-sweeps/{registration_id}" + entries: list[dict[str, Any]] = [] + for rung in spec["rungs"]: + for shift_value in spec["preconditioner_absorption_shifts"]: + shift = float(shift_value) + run_id = f"{rung['id']}-shift-{_shift_label(shift)}" + run_root = f"{output_root}/{run_id}" + container_run_root = f"/artifacts/{run_id}" + fem_smoke_json = f"{run_root}/fem-smoke.json" + container_fem_smoke_json = f"{container_run_root}/fem-smoke.json" + entries.append( + { + "run_id": run_id, + "rung_id": rung["id"], + "preconditioner_absorption_shift": shift, + "status": "NOT RUN", + "passed": None, + "resource_contract": { + "mpi_ranks": rung["mpi_ranks"], + "cgroup_memory_limit_bytes": spec[ + "cgroup_memory_limit_bytes" + ], + "wall_time_limit_seconds": spec[ + "wall_time_limit_seconds" + ], + }, + "physical_problem": _expected_physical_problem( + subdivisions=rung["subdivisions"], + frequency_hz=spec["physical_problem"]["frequency_hz"], + degree=spec["physical_problem"]["fine_degree"], + ), + "command": _solver_command( + spec=spec, + rung=rung, + shift=shift, + output=container_fem_smoke_json, + ), + "outputs": { + "directory": run_root, + "container_directory": container_run_root, + "fem_smoke_json": fem_smoke_json, + "container_fem_smoke_json": container_fem_smoke_json, + "stdout_log": f"{run_root}/stdout.log", + "stderr_log": f"{run_root}/stderr.log", + "exit_code_json": f"{run_root}/exit-code.json", + "sha256_manifest": f"{run_root}/SHA256SUMS", + }, + } + ) + return { + "schema": REGISTRATION_SCHEMA, + "registration_id": registration_id, + "status_vocabulary": list(STATUS_VOCABULARY), + "specification": { + "schema": spec["schema"], + "canonical_path": spec["canonical_path"], + "sha256": spec_sha256, + }, + "source": validated_source, + "images": images, + "output_root": output_root, + "registration_contract": { + "must_exist_before_first_solve": True, + "entries_are_initial_and_immutable": True, + "results_are_written_separately": True, + "overwrite_permitted": False, + }, + "entries": entries, + } + + +def write_registration(payload: Mapping[str, Any], path: Path) -> Path: + """Atomically publish a registration without ever replacing an existing file.""" + + destination = path.expanduser().resolve() + destination.parent.mkdir(parents=True, exist_ok=True) + encoded = json.dumps( + payload, indent=2, sort_keys=True, ensure_ascii=True, allow_nan=False + ) + "\n" + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=destination.parent, + delete=False, + ) as handle: + handle.write(encoded) + handle.flush() + os.fsync(handle.fileno()) + temporary = Path(handle.name) + if os.name == "posix": + temporary.chmod(0o644) + try: + try: + os.link(temporary, destination) + except FileExistsError as exc: + raise FileExistsError( + f"refusing to overwrite existing scaling sweep registration: {destination}" + ) from exc + finally: + temporary.unlink(missing_ok=True) + return destination + + +def main() -> int: + repository_default = Path(__file__).resolve().parents[1] + parser = argparse.ArgumentParser() + parser.add_argument( + "--spec", + type=Path, + default=Path(__file__).with_name("scaling_sweep_v1.json"), + ) + parser.add_argument("--repository", type=Path, default=repository_default) + parser.add_argument( + "--project-image-kind", + choices=("oci_digest", "local_image_id"), + required=True, + ) + parser.add_argument("--project-image-identity", required=True) + parser.add_argument("--base-image-digest", required=True) + parser.add_argument("--base-runtime-metadata", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + args = parser.parse_args() + + try: + spec_bytes = args.spec.read_bytes() + spec = json.loads(spec_bytes) + if not isinstance(spec, Mapping): + raise ValueError("scaling sweep specification must be a JSON object") + runtime_metadata = json.loads(args.base_runtime_metadata.read_bytes()) + if not isinstance(runtime_metadata, Mapping): + raise ValueError("base runtime metadata must be a JSON object") + payload = build_registration( + spec, + spec_bytes, + source=_git_source(args.repository), + project_image_kind=args.project_image_kind, + project_image_identity=args.project_image_identity, + base_image_digest=args.base_image_digest, + base_runtime_metadata=runtime_metadata, + ) + destination = write_registration(payload, args.output) + except (FileExistsError, FileNotFoundError, json.JSONDecodeError, ValueError) as exc: + parser.error(str(exc)) + print(destination) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/validation/remote_capacity_preflight.py b/validation/remote_capacity_preflight.py new file mode 100644 index 0000000..10a7757 --- /dev/null +++ b/validation/remote_capacity_preflight.py @@ -0,0 +1,365 @@ +#!/usr/bin/env python3 +"""Fail-closed filesystem-capacity preflight for disposable Linux runners. + +The report deliberately contains labels and filesystem statistics, but not paths, +hostnames, environment variables, or raw operating-system errors. This keeps the +machine-independent JSON suitable for evidence archives without leaking runner +identity or credential-bearing path components. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +from collections.abc import Callable, Iterable, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Any, NoReturn + +SCHEMA = "scatter3d.validation.remote_capacity_preflight/v1" +PASSED = "PASSED" +FAILED = "FAILED" +MAXIMUM_FREE_BYTES = (1 << 63) - 1 + +_SAFE_LABEL = re.compile(r"^[a-z][a-z0-9_-]{0,63}$") +_POSITIVE_INTEGER = re.compile(r"^[1-9][0-9]*$") +_MAXIMUM_FREE_BYTES_TEXT = str(MAXIMUM_FREE_BYTES) + + +class CapacityPreflightInputError(ValueError): + """An input error with a stable, non-sensitive public error code.""" + + def __init__(self, code: str, message: str) -> None: + super().__init__(message) + self.code = code + + +class _SanitizedArgumentParser(argparse.ArgumentParser): + """Convert argparse failures into stable evidence without echoing argv.""" + + def error(self, message: str) -> NoReturn: + del message + raise CapacityPreflightInputError( + "INVALID_COMMAND_LINE", + "command-line arguments are invalid", + ) + + +@dataclass(frozen=True, slots=True) +class LabeledPath: + """A validated capacity target; its path is intentionally not serialized.""" + + label: str + path: Path + + +def _resolve_existing_absolute_path(candidate: Path) -> Path: + try: + absolute = candidate.is_absolute() + except (OSError, RuntimeError, ValueError): + raise CapacityPreflightInputError( + "PATH_NOT_ACCESSIBLE", + "capacity paths must exist and be accessible", + ) from None + if not absolute: + raise CapacityPreflightInputError( + "PATH_NOT_ABSOLUTE", + "capacity paths must be absolute", + ) + try: + return candidate.resolve(strict=True) + except (OSError, RuntimeError, ValueError): + raise CapacityPreflightInputError( + "PATH_NOT_ACCESSIBLE", + "capacity paths must exist and be accessible", + ) from None + + +def parse_minimum_free_bytes(value: int | str) -> int: + """Return a strictly positive byte threshold or raise a stable input error.""" + + if isinstance(value, bool): + raise CapacityPreflightInputError( + "INVALID_MINIMUM_FREE_BYTES", + "minimum free bytes must be a positive base-10 integer", + ) + if isinstance(value, int): + threshold = value + elif isinstance(value, str) and _POSITIVE_INTEGER.fullmatch(value) is not None: + if len(value) > len(_MAXIMUM_FREE_BYTES_TEXT) or ( + len(value) == len(_MAXIMUM_FREE_BYTES_TEXT) and value > _MAXIMUM_FREE_BYTES_TEXT + ): + raise CapacityPreflightInputError( + "INVALID_MINIMUM_FREE_BYTES", + "minimum free bytes must be a positive base-10 integer", + ) + threshold = int(value) + else: + raise CapacityPreflightInputError( + "INVALID_MINIMUM_FREE_BYTES", + "minimum free bytes must be a positive base-10 integer", + ) + if threshold <= 0 or threshold > MAXIMUM_FREE_BYTES: + raise CapacityPreflightInputError( + "INVALID_MINIMUM_FREE_BYTES", + "minimum free bytes must be a positive base-10 integer", + ) + return threshold + + +def parse_labeled_paths(specifications: Iterable[str]) -> tuple[LabeledPath, ...]: + """Validate ``LABEL=ABSOLUTE_PATH`` specifications and return label order. + + Labels are intentionally limited to a small ASCII vocabulary so they cannot + inject terminal control characters, path fragments, or structured-output + delimiters into evidence. Paths must already exist and resolve strictly. + """ + + targets: list[LabeledPath] = [] + labels: set[str] = set() + for specification in specifications: + if not isinstance(specification, str) or "=" not in specification: + raise CapacityPreflightInputError( + "MALFORMED_PATH_SPECIFICATION", + "each path must use LABEL=ABSOLUTE_PATH", + ) + label, raw_path = specification.split("=", 1) + if _SAFE_LABEL.fullmatch(label) is None: + raise CapacityPreflightInputError( + "UNSAFE_PATH_LABEL", + "path labels must match [a-z][a-z0-9_-]{0,63}", + ) + if label in labels: + raise CapacityPreflightInputError( + "DUPLICATE_PATH_LABEL", + "path labels must be unique", + ) + try: + candidate = Path(raw_path) + except (TypeError, ValueError): + raise CapacityPreflightInputError( + "PATH_NOT_ACCESSIBLE", + "capacity paths must exist and be accessible", + ) from None + resolved = _resolve_existing_absolute_path(candidate) + labels.add(label) + targets.append(LabeledPath(label=label, path=resolved)) + if not targets: + raise CapacityPreflightInputError( + "NO_PATHS", + "at least one labeled path is required", + ) + return tuple(sorted(targets, key=lambda item: item.label)) + + +def _system_statvfs(path: Path) -> Any: + """Call the POSIX filesystem API without importing or invoking shell tools.""" + + implementation = getattr(os, "statvfs", None) + if implementation is None: + raise OSError("statvfs is unavailable") + return implementation(path) + + +def _nonnegative_integer(value: Any) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise ValueError + return value + + +def _filesystem_bytes(statistics: Any) -> tuple[int, int]: + """Return total and unprivileged-available bytes from a statvfs result.""" + + try: + fragment_size = _nonnegative_integer(statistics.f_frsize) + total_blocks = _nonnegative_integer(statistics.f_blocks) + available_blocks = _nonnegative_integer(statistics.f_bavail) + except (AttributeError, TypeError, ValueError) as exc: + raise ValueError("invalid statvfs result") from exc + if fragment_size == 0 or available_blocks > total_blocks: + raise ValueError("invalid statvfs result") + return total_blocks * fragment_size, available_blocks * fragment_size + + +def _validated_targets(targets: Iterable[LabeledPath]) -> tuple[LabeledPath, ...]: + """Independently validate objects supplied to the public evaluator.""" + + try: + supplied = tuple(targets) + except TypeError: + raise CapacityPreflightInputError( + "INVALID_CAPACITY_TARGET", + "capacity targets must be validated labeled paths", + ) from None + if not supplied: + raise CapacityPreflightInputError( + "NO_PATHS", + "at least one labeled path is required", + ) + + labels: set[str] = set() + validated: list[LabeledPath] = [] + for target in supplied: + if ( + not isinstance(target, LabeledPath) + or not isinstance(target.label, str) + or not isinstance(target.path, Path) + ): + raise CapacityPreflightInputError( + "INVALID_CAPACITY_TARGET", + "capacity targets must be validated labeled paths", + ) + if _SAFE_LABEL.fullmatch(target.label) is None: + raise CapacityPreflightInputError( + "UNSAFE_PATH_LABEL", + "path labels must match [a-z][a-z0-9_-]{0,63}", + ) + if target.label in labels: + raise CapacityPreflightInputError( + "DUPLICATE_PATH_LABEL", + "path labels must be unique", + ) + labels.add(target.label) + validated.append( + LabeledPath( + label=target.label, + path=_resolve_existing_absolute_path(target.path), + ) + ) + return tuple(sorted(validated, key=lambda item: item.label)) + + +def evaluate_capacity( + targets: Iterable[LabeledPath], + minimum_free_bytes: int | str, + *, + statvfs: Callable[[Path], Any] = _system_statvfs, +) -> dict[str, Any]: + """Evaluate validated targets and return a deterministic evidence object.""" + + threshold = parse_minimum_free_bytes(minimum_free_bytes) + ordered = _validated_targets(targets) + + checks: list[dict[str, Any]] = [] + for target in ordered: + try: + total_bytes, available_bytes = _filesystem_bytes(statvfs(target.path)) + except (OSError, ValueError): + checks.append( + { + "available_bytes": None, + "label": target.label, + "minimum_free_bytes": threshold, + "passed": False, + "reason": "filesystem statistics unavailable or invalid", + "reason_code": "STATVFS_FAILED", + "shortfall_bytes": None, + "status": FAILED, + "total_bytes": None, + } + ) + continue + + passed = available_bytes >= threshold + checks.append( + { + "available_bytes": available_bytes, + "label": target.label, + "minimum_free_bytes": threshold, + "passed": passed, + "reason": ( + "available bytes meet the required minimum" + if passed + else "available bytes are below the required minimum" + ), + "reason_code": "ENOUGH_FREE_BYTES" if passed else "INSUFFICIENT_FREE_BYTES", + "shortfall_bytes": max(threshold - available_bytes, 0), + "status": PASSED if passed else FAILED, + "total_bytes": total_bytes, + } + ) + + passed = all(check["status"] == PASSED for check in checks) + return { + "checks": checks, + "error": None, + "minimum_free_bytes": threshold, + "passed": passed, + "schema": SCHEMA, + "status": PASSED if passed else FAILED, + } + + +def failed_input_report(error: CapacityPreflightInputError) -> dict[str, Any]: + """Return deterministic, sanitized JSON for rejected input.""" + + return { + "checks": [], + "error": {"code": error.code, "message": str(error)}, + "minimum_free_bytes": None, + "passed": False, + "schema": SCHEMA, + "status": FAILED, + } + + +def run_preflight( + path_specifications: Iterable[str], + minimum_free_bytes: int | str, + *, + statvfs: Callable[[Path], Any] = _system_statvfs, +) -> dict[str, Any]: + """Parse and evaluate a preflight, preserving input failures as JSON.""" + + try: + threshold = parse_minimum_free_bytes(minimum_free_bytes) + targets = parse_labeled_paths(path_specifications) + return evaluate_capacity(targets, threshold, statvfs=statvfs) + except CapacityPreflightInputError as exc: + return failed_input_report(exc) + + +def encode_report(report: dict[str, Any]) -> str: + """Serialize a report with stable ordering and strict JSON values.""" + + return json.dumps( + report, + allow_nan=False, + ensure_ascii=True, + indent=2, + sort_keys=True, + ) + + +def main(argv: Sequence[str] | None = None) -> int: + parser = _SanitizedArgumentParser( + allow_abbrev=False, + description="Fail closed unless every labeled filesystem has enough free bytes.", + ) + parser.add_argument( + "--path", + action="append", + default=[], + dest="paths", + metavar="LABEL=ABSOLUTE_PATH", + help="filesystem path to check; repeat for each required filesystem", + ) + parser.add_argument("--minimum-free-bytes", required=True) + try: + args = parser.parse_args(argv) + except CapacityPreflightInputError as exc: + print(encode_report(failed_input_report(exc))) + return 1 + report = run_preflight( + args.paths, + args.minimum_free_bytes, + statvfs=_system_statvfs, + ) + print(encode_report(report)) + return 0 if report["status"] == PASSED else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/validation/run_registered_scaling_sweep.py b/validation/run_registered_scaling_sweep.py new file mode 100644 index 0000000..d040a55 --- /dev/null +++ b/validation/run_registered_scaling_sweep.py @@ -0,0 +1,1762 @@ +#!/usr/bin/env python3 +"""Execute an immutable registered scaling sweep through host-side Docker.""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import math +import os +import secrets +import stat +import subprocess +import tempfile +import time +from collections.abc import Callable, Mapping, Sequence +from pathlib import Path +from typing import Any + +from validation import register_scaling_sweep + +RESULT_SCHEMA = "scatter3d.validation.scaling_sweep_run_result/v1" +FEM_SMOKE_SCHEMA = "scatter3d.validation.fem_smoke/v2" + + +def _parse_container_inspection( + payload: bytes, +) -> tuple[Mapping[str, Any], Mapping[str, Any]]: + try: + candidate = json.loads(payload) + except (json.JSONDecodeError, UnicodeDecodeError) as exc: + raise ValueError("docker inspect returned malformed JSON") from exc + if not isinstance(candidate, Mapping): + raise ValueError("docker inspect did not return an object") + state = candidate.get("State") + host_config = candidate.get("HostConfig") + if not isinstance(state, Mapping) or not isinstance(host_config, Mapping): + raise ValueError("docker inspect is missing State or HostConfig") + for key in ("Running", "OOMKilled"): + if not isinstance(state.get(key), bool): + raise ValueError(f"docker inspect State.{key} must be boolean") + exit_code = state.get("ExitCode") + if not isinstance(exit_code, int) or isinstance(exit_code, bool): + raise ValueError("docker inspect State.ExitCode must be an integer") + pid = state.get("Pid") + if not isinstance(pid, int) or isinstance(pid, bool) or pid < 0: + raise ValueError("docker inspect State.Pid must be a nonnegative integer") + for key in ("StartedAt", "FinishedAt"): + if not isinstance(state.get(key), str): + raise ValueError(f"docker inspect State.{key} must be a string") + return state, host_config + + +def _resolve_host_cgroup(pid: int) -> Path: + """Resolve a running container's unified host cgroup without layout guesses.""" + + if pid <= 0: + raise ValueError("running container PID must be positive") + lines = Path(f"/proc/{pid}/cgroup").read_text(encoding="utf-8").splitlines() + relative = None + for line in lines: + pieces = line.split(":", 2) + if len(pieces) == 3 and pieces[0] == "0" and pieces[1] == "": + relative = pieces[2] + break + if relative is None: + raise ValueError("container has no unified cgroup-v2 path") + + return _resolve_cgroup_relative_path(relative, Path("/sys/fs/cgroup")) + + +def _resolve_cgroup_relative_path(relative: str, root: Path) -> Path: + """Resolve one cgroup-v2 relative path beneath a caller-supplied root.""" + + parts = Path(relative.lstrip("/")).parts + if not parts or ".." in parts: + raise ValueError("container cgroup path is empty or contains traversal") + resolved_root = root.resolve() + candidate = resolved_root.joinpath(*parts).resolve() + if not candidate.is_relative_to(resolved_root) or not candidate.is_dir(): + raise ValueError("container cgroup path escapes or is absent from host root") + return candidate + + +def _read_cgroup_v2_metrics(path: Path) -> dict[str, Any]: + def read_limit(name: str) -> int | str: + raw = path.joinpath(name).read_text(encoding="utf-8").strip() + if raw == "max": + return raw + value = int(raw) + if value < 0: + raise ValueError(f"{name} must be nonnegative") + return value + + events: dict[str, int] = {} + for line in path.joinpath("memory.events").read_text(encoding="utf-8").splitlines(): + key, raw = line.split() + value = int(raw) + if value < 0: + raise ValueError("memory.events values must be nonnegative") + events[key] = value + required_events = {"oom", "oom_kill", "max"} + if not required_events.issubset(events): + raise ValueError("memory.events is missing required counters") + peak = read_limit("memory.peak") + if not isinstance(peak, int) or peak <= 0: + raise ValueError("memory.peak must be a positive finite byte count") + return { + "version": "v2-host", + "path": str(path), + "peak_bytes": peak, + "limit_bytes": read_limit("memory.max"), + "swap_limit_bytes": read_limit("memory.swap.max"), + "events": events, + } + + +def _cgroup_is_populated(path: Path) -> bool: + values = {} + for line in path.joinpath("cgroup.events").read_text(encoding="utf-8").splitlines(): + key, raw = line.split() + values[key] = int(raw) + if values.get("populated") not in {0, 1}: + raise ValueError("cgroup.events is missing populated=0|1") + return values["populated"] == 1 + + +def _read_json_object(path: Path, label: str) -> tuple[dict[str, Any], bytes]: + encoded = path.read_bytes() + payload = json.loads(encoded) + if not isinstance(payload, Mapping): + raise ValueError(f"{label} must be a JSON object") + return dict(payload), encoded + + +def _publish_bytes(payload: bytes, destination: Path) -> Path: + destination.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile(dir=destination.parent, delete=False) as handle: + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + temporary = Path(handle.name) + if os.name == "posix": + temporary.chmod(0o644) + try: + try: + os.link(temporary, destination) + except FileExistsError as exc: + raise FileExistsError(f"refusing to overwrite run evidence: {destination}") from exc + finally: + temporary.unlink(missing_ok=True) + return destination + + +def _publish_json(payload: Mapping[str, Any], destination: Path) -> Path: + encoded = json.dumps( + payload, indent=2, sort_keys=True, ensure_ascii=True, allow_nan=False + ).encode("utf-8") + b"\n" + return _publish_bytes(encoded, destination) + + +def validate_image_inspection( + registration: Mapping[str, Any], image: Mapping[str, Any] +) -> str: + """Validate immutable image identities and required provenance labels.""" + + images = registration.get("images") + source = registration.get("source") + if not isinstance(images, Mapping) or not isinstance(source, Mapping): + raise ValueError("registration image/source identities are incomplete") + project = images.get("project_image") + base = images.get("base_image") + if not isinstance(project, Mapping) or not isinstance(base, Mapping): + raise ValueError("registration image identities are incomplete") + image_id = image.get("Id") + if not isinstance(image_id, str): + raise ValueError("Docker inspection is missing image Id") + image_id = register_scaling_sweep._validated_image_identity( + image_id, "Docker image Id" + ) + project_kind = project.get("kind") + project_identity = project.get("identity") + if project_kind == "local_image_id": + if image_id != project_identity: + raise ValueError("Docker local image Id differs from registration") + elif project_kind == "oci_digest": + repo_digests = image.get("RepoDigests") + if not isinstance(repo_digests, Sequence) or isinstance(repo_digests, str): + raise ValueError("Docker inspection is missing RepoDigests") + digests = { + value.rsplit("@", 1)[-1].lower() + for value in repo_digests + if isinstance(value, str) and "@" in value + } + if project_identity not in digests: + raise ValueError("registered OCI project digest is absent from RepoDigests") + else: + raise ValueError("registration project image kind is unsupported") + config = image.get("Config") + labels = config.get("Labels") if isinstance(config, Mapping) else None + if not isinstance(labels, Mapping): + raise ValueError("Docker image provenance labels are missing") + if labels.get("org.opencontainers.image.revision") != source.get("commit"): + raise ValueError("Docker image revision label differs from registration source") + if labels.get("org.opencontainers.image.base.digest") != base.get("identity"): + raise ValueError("Docker base digest label differs from registration") + environment = config.get("Env") + if not isinstance(environment, Sequence) or isinstance(environment, str): + raise ValueError("Docker image provenance environment is missing") + environment_map = { + item.split("=", 1)[0]: item.split("=", 1)[1] + for item in environment + if isinstance(item, str) and "=" in item + } + if ( + environment_map.get("SCATTER3D_GIT_COMMIT") != source.get("commit") + or environment_map.get("SCATTER3D_GIT_DIRTY") != "false" + ): + raise ValueError("Docker image source environment differs from registration") + return image_id + + +def validate_execution_context( + registration: Mapping[str, Any], + *, + spec: Mapping[str, Any], + spec_bytes: bytes, + source: Mapping[str, Any], + runtime_metadata: Mapping[str, Any], +) -> None: + """Regenerate the complete registration from current immutable identities.""" + + images = registration.get("images") + if not isinstance(images, Mapping): + raise ValueError("registration images mapping is missing") + project = images.get("project_image") + base = images.get("base_image") + if not isinstance(project, Mapping) or not isinstance(base, Mapping): + raise ValueError("registration image identities are incomplete") + expected = register_scaling_sweep.build_registration( + spec, + spec_bytes, + source=source, + project_image_kind=str(project.get("kind")), + project_image_identity=str(project.get("identity")), + base_image_digest=str(base.get("identity")), + base_runtime_metadata=runtime_metadata, + ) + if dict(registration) != expected: + raise ValueError( + "registration is incomplete or differs from current spec/source/image/runtime identities" + ) + + +def build_docker_create_command( + registration: Mapping[str, Any], + entry: Mapping[str, Any], + *, + image_reference: str, + host_run_directory: Path, + attempt_id: str | None = None, +) -> list[str]: + resource = entry["resource_contract"] + memory_bytes = int(resource["cgroup_memory_limit_bytes"]) + project = registration["images"]["project_image"] + base = registration["images"]["base_image"] + project_variable = ( + "SCATTER3D_PROJECT_IMAGE_ID" + if project["kind"] == "local_image_id" + else "SCATTER3D_PROJECT_IMAGE_DIGEST" + ) + container_name = ( + f"scatter3d-{str(registration['registration_id'])[:12]}-" + f"{entry['run_id']}" + ) + attempt = attempt_id or secrets.token_hex(16) + return [ + "docker", + "create", + "--name", + container_name, + "--label", + f"scatter3d.registration_id={registration['registration_id']}", + "--label", + f"scatter3d.run_id={entry['run_id']}", + "--label", + f"scatter3d.attempt_id={attempt}", + "--memory", + str(memory_bytes), + "--memory-swap", + str(memory_bytes), + "--ipc=host", + "--env", + f"{project_variable}={project['identity']}", + "--env", + f"SCATTER3D_BASE_IMAGE_DIGEST={base['identity']}", + "--volume", + f"{host_run_directory}:{entry['outputs']['container_directory']}", + "--workdir", + "/opt/scatter3d", + image_reference, + "sh", + "-c", + ( + f"run={entry['outputs']['container_directory']}; " + "touch \"$run/.executor-ready\" && " + "while [ ! -e \"$run/.executor-go\" ]; do sleep 0.05; done; " + "\"$@\"; rc=$?; " + "printf '%s\\n' \"$rc\" > \"$run/.solver-exit-code\"; " + "touch \"$run/.solver-done\"; " + "while [ ! -e \"$run/.executor-collected\" ]; do sleep 0.05; done; " + "exit \"$rc\"" + ), + "scatter3d-executor", + *entry["command"], + ] + + +def classify_run_result( + *, + docker_return_code: int | None, + fem_payload: Mapping[str, Any] | None, + launch_prevented: bool, +) -> tuple[str, bool | None, str]: + if fem_payload is not None: + status = fem_payload.get("status") + passed = fem_payload.get("passed") + if status == "PASSED" and passed is True and docker_return_code == 0: + return "PASSED", True, "fem_smoke artifact and Docker exit code passed" + if status == "FAILED" and passed is False and docker_return_code == 1: + return "FAILED", False, "fem_smoke artifact reported FAILED" + return "FAILED", False, "fem_smoke artifact status/exit code is inconsistent" + if launch_prevented: + return "BLOCKED", None, "Docker could not launch the registered solve" + return "FAILED", False, "registered solve produced no fem_smoke artifact" + + +def _registered_argument(entry: Mapping[str, Any], name: str) -> str: + argv = entry["command"] + try: + return str(argv[argv.index(name) + 1]) + except (ValueError, IndexError) as exc: + raise ValueError(f"registered command is missing {name}") from exc + + +def _required_mapping(value: Any, label: str) -> Mapping[str, Any]: + if not isinstance(value, Mapping): + raise ValueError(f"fem_smoke {label} is missing or malformed") + return value + + +def _required_sequence(value: Any, label: str) -> Sequence[Any]: + if not isinstance(value, Sequence) or isinstance(value, str | bytes): + raise ValueError(f"fem_smoke {label} is missing or malformed") + return value + + +def _finite_number(value: Any, label: str, *, positive: bool = False) -> float: + if ( + not isinstance(value, int | float) + or isinstance(value, bool) + or not math.isfinite(float(value)) + or (positive and float(value) <= 0.0) + or (not positive and float(value) < 0.0) + ): + qualifier = "positive" if positive else "nonnegative" + raise ValueError(f"fem_smoke {label} must be a finite {qualifier} number") + return float(value) + + +def _residual_number(value: Any, label: str) -> float: + if isinstance(value, int | float) and not isinstance(value, bool): + normalized = float(value) + if not math.isfinite(normalized) or normalized < 0.0: + raise ValueError( + f"fem_smoke {label} must be a finite nonnegative number" + ) + return normalized + if value in {"NaN", "+Infinity", "-Infinity"}: + return math.inf + raise ValueError(f"fem_smoke {label} is missing or malformed") + + +def _component_has_identity(component: Mapping[str, Any], label: str) -> None: + for key in ("ksp_type", "pc_type", "options_prefix"): + if not isinstance(component.get(key), str) or not component[key]: + raise ValueError(f"fem_smoke {label} is missing {key}") + instances = component.get("instances") + ranks = component.get("mpi_ranks") + if not isinstance(instances, int) or isinstance(instances, bool) or instances <= 0: + raise ValueError(f"fem_smoke {label} has invalid instances") + if ( + not isinstance(ranks, Sequence) + or isinstance(ranks, str | bytes) + or not ranks + or any(not isinstance(rank, int) or isinstance(rank, bool) for rank in ranks) + ): + raise ValueError(f"fem_smoke {label} has invalid MPI ranks") + + +def _validate_completed_fem_evidence( + entry: Mapping[str, Any], payload: Mapping[str, Any] +) -> dict[str, bool]: + """Recompute completed-run gates from the recorded numerical evidence.""" + + expected_frequency = float(_registered_argument(entry, "--frequencies-hz")) + expected_dofs = int(_registered_argument(entry, "--expected-global-dofs")) + expected_degree = int(_registered_argument(entry, "--degree")) + expected_coarse_degree = int( + _registered_argument(entry, "--p-multigrid-coarse-degree") + ) + expected_subdivisions = int(_registered_argument(entry, "--subdivisions")) + maximum_iterations = int(_registered_argument(entry, "--maximum-iterations")) + maximum_residual = float( + _registered_argument(entry, "--maximum-true-relative-residual") + ) + gmres_restart = int(_registered_argument(entry, "--gmres-restart")) + asm_overlap = int(_registered_argument(entry, "--asm-overlap")) + shift = float(_registered_argument(entry, "--preconditioner-absorption-shift")) + mpi_ranks = int(entry["resource_contract"]["mpi_ranks"]) + expected_ranks = list(range(mpi_ranks)) + + if payload.get("solver") != "iterative": + raise ValueError("fem_smoke completed artifact is not iterative") + if payload.get("iterative_hierarchy") != "p-multigrid": + raise ValueError("fem_smoke completed artifact is not p-multigrid") + if payload.get("degree") != expected_degree: + raise ValueError("fem_smoke fine degree differs from registration") + if payload.get("subdivisions") != expected_subdivisions: + raise ValueError("fem_smoke subdivisions differ from registration") + if payload.get("frequencies_hz") != [expected_frequency]: + raise ValueError("fem_smoke frequencies differ from registration") + if "complex128" not in str(payload.get("scalar_type")): + raise ValueError("fem_smoke scalar type is not complex128") + + requested_options = { + "ksp_gmres_restart": gmres_restart, + "mg_coarse_ksp_type": "preonly", + "mg_coarse_pc_factor_mat_solver_type": "mumps", + "mg_coarse_pc_type": "lu", + "mg_levels_1_ksp_max_it": 1, + "mg_levels_1_ksp_type": "richardson", + "mg_levels_1_pc_asm_overlap": asm_overlap, + "mg_levels_1_pc_type": "asm", + "mg_levels_1_sub_ksp_type": "preonly", + "mg_levels_1_sub_pc_factor_mat_solver_type": "mumps", + "mg_levels_1_sub_pc_type": "lu", + } + solver_configuration = _required_mapping( + payload.get("solver_configuration"), "solver_configuration" + ) + requested_solver = _required_mapping( + solver_configuration.get("requested"), + "solver_configuration.requested", + ) + required_requested = { + "solver": "iterative", + "iterative_hierarchy": "p-multigrid", + "ksp_type": "fgmres", + "pc_type": "mg", + "preconditioning_side": "right", + "maximum_iterations": maximum_iterations, + "p_multigrid_coarse_degree": expected_coarse_degree, + "preconditioner_absorption_shift": shift, + } + if any(requested_solver.get(key) != value for key, value in required_requested.items()): + raise ValueError("fem_smoke requested solver differs from registration") + if requested_solver.get("petsc_options") != requested_options: + raise ValueError("fem_smoke requested PETSc options differ from registration") + + diagnostics = _required_sequence( + payload.get("frequency_diagnostics"), "frequency_diagnostics" + ) + if len(diagnostics) != 1: + raise ValueError("fem_smoke must contain one registered frequency diagnostic") + item = _required_mapping(diagnostics[0], "frequency diagnostic") + if item.get("frequency_hz") != expected_frequency: + raise ValueError("fem_smoke diagnostic frequency differs from registration") + if item.get("global_complex_dofs") != expected_dofs: + raise ValueError("fem_smoke diagnostic global DoFs differ from registration") + if item.get("fine_degree") != expected_degree: + raise ValueError("fem_smoke diagnostic fine degree differs from registration") + coarse_dofs = item.get("coarse_global_complex_dofs") + if ( + item.get("coarse_degree") != expected_coarse_degree + or not isinstance(coarse_dofs, int) + or isinstance(coarse_dofs, bool) + or not 0 < coarse_dofs < expected_dofs + ): + raise ValueError("fem_smoke coarse-space identity is missing or malformed") + for key in ( + "matrix_nonzeros", + "preconditioner_matrix_nonzeros", + "coarse_preconditioner_matrix_nonzeros", + "rank_peak_rss_bytes_max", + "rank_peak_rss_bytes_sum", + ): + value = item.get(key) + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + raise ValueError(f"fem_smoke diagnostic {key} is missing or malformed") + for key in ( + "assembly_seconds", + "preconditioner_assembly_seconds", + "coarse_preconditioner_assembly_seconds", + "setup_seconds", + ): + _finite_number(item.get(key), f"diagnostic {key}") + if item.get("preconditioner_absorption_shift") != shift: + raise ValueError("fem_smoke diagnostic shift differs from registration") + expected_physical_preconditioner = shift == 0.0 + if item.get("preconditioner_operator_is_physical") is not ( + expected_physical_preconditioner + ): + raise ValueError("fem_smoke A/P operator identity differs from registration") + if not isinstance(item.get("p_multigrid_operator_checks_passed"), bool): + raise ValueError("fem_smoke p-multigrid operator check is missing") + + transfer = _required_mapping(item.get("transfer_operator"), "transfer operator") + transfer_well_formed = ( + transfer.get("direction") == "coarse_to_fine" + and transfer.get("rows") == expected_dofs + and transfer.get("columns") == coarse_dofs + and isinstance(transfer.get("nonzeros"), int) + and not isinstance(transfer.get("nonzeros"), bool) + and transfer["nonzeros"] > 0 + and isinstance(transfer.get("constrained_fine_rows"), int) + and transfer["constrained_fine_rows"] > 0 + and isinstance(transfer.get("constrained_coarse_columns"), int) + and transfer["constrained_coarse_columns"] > 0 + and transfer.get("maximum_imaginary_abs") == 0.0 + ) + _finite_number(transfer.get("assembly_seconds"), "transfer assembly_seconds") + + hierarchy = _required_mapping(item.get("solver_hierarchy"), "solver hierarchy") + requested_hierarchy = _required_mapping( + hierarchy.get("requested"), "requested solver hierarchy" + ) + requested_pairs = _required_sequence( + requested_hierarchy.get("petsc_options"), "requested hierarchy PETSc options" + ) + try: + hierarchy_options = dict(requested_pairs) + except (TypeError, ValueError) as exc: + raise ValueError("fem_smoke requested hierarchy options are malformed") from exc + requested_hierarchy_ok = ( + requested_hierarchy.get("ksp_type") == "fgmres" + and requested_hierarchy.get("pc_type") == "mg" + and requested_hierarchy.get("preconditioning_side") == "right" + and hierarchy_options == requested_options + ) + effective = _required_mapping( + hierarchy.get("effective"), "effective solver hierarchy" + ) + top = _required_mapping(effective.get("top_level"), "effective top-level solver") + fine = _required_mapping(effective.get("mg_fine_smoother"), "effective fine smoother") + coarse = _required_mapping(effective.get("mg_coarse_solver"), "effective coarse solver") + subdomains = _required_sequence( + effective.get("mg_fine_asm_subdomain_solvers"), + "effective ASM subdomain solvers", + ) + if not subdomains: + raise ValueError("fem_smoke effective ASM subdomain solvers are empty") + _component_has_identity(top, "effective top-level solver") + _component_has_identity(fine, "effective fine smoother") + _component_has_identity(coarse, "effective coarse solver") + for index, component in enumerate(subdomains): + _component_has_identity( + _required_mapping(component, f"effective ASM subdomain solver {index}"), + f"effective ASM subdomain solver {index}", + ) + component_contracts = ( + ( + top, + "top", + "scatter3d_0_", + maximum_iterations, + "unpreconditioned", + ), + ( + fine, + "mg.fine", + "scatter3d_0_mg_levels_1_", + 1, + "none", + ), + ( + coarse, + "mg.coarse", + "scatter3d_0_mg_coarse_", + 10_000, + "none", + ), + ) + if any( + component.get("path") != path + or component.get("options_prefix") != prefix + or component.get("instances") != mpi_ranks + or component.get("mpi_ranks") != expected_ranks + or component.get("maximum_iterations") != iterations + or component.get("norm_type") != norm_type + for component, path, prefix, iterations, norm_type in component_contracts + ): + raise ValueError("fem_smoke effective hierarchy component identity is inconsistent") + if len(subdomains) != 1: + raise ValueError("fem_smoke must contain one aggregated ASM subdomain identity") + subdomain = _required_mapping(subdomains[0], "effective ASM subdomain solver") + if ( + subdomain.get("path") != "mg.fine.asm.subdomains" + or subdomain.get("options_prefix") != "scatter3d_0_mg_levels_1_sub_" + or subdomain.get("instances") != mpi_ranks + or subdomain.get("mpi_ranks") != expected_ranks + or subdomain.get("maximum_iterations") != 10_000 + or subdomain.get("norm_type") != "none" + ): + raise ValueError("fem_smoke effective ASM subdomain identity is inconsistent") + raw_view = effective.get("petsc_view_ascii") + if not isinstance(raw_view, str) or not raw_view.strip(): + raise ValueError("fem_smoke raw PETSc KSP view is missing") + raw_view_required = ( + "type: fgmres", + "right preconditioning", + "type: mg", + "levels=2", + "not using galerkin", + "type: richardson", + "type: asm", + "type: preonly", + "type: lu", + "package used to perform factorization: mumps", + ) + if any(fragment not in raw_view.lower() for fragment in raw_view_required): + raise ValueError("fem_smoke raw PETSc KSP view lacks required hierarchy evidence") + hierarchy_ok = ( + requested_hierarchy_ok + and top.get("ksp_type") == "fgmres" + and top.get("pc_type") == "mg" + and effective.get("preconditioning_side") == "right" + and effective.get("pc_uses_amat") is False + and effective.get("mg_levels") == 2 + and effective.get("mg_type") == "multiplicative" + and effective.get("mg_cycle_type") == "v" + and effective.get("mg_galerkin") == "none" + and effective.get("maximum_iterations") == maximum_iterations + and effective.get("relative_tolerance") == 1.0e-8 + and effective.get("absolute_tolerance") == 1.0e-12 + and fine.get("ksp_type") == "richardson" + and fine.get("pc_type") == "asm" + and fine.get("maximum_iterations") == 1 + and fine.get("norm_type") == "none" + and effective.get("mg_fine_asm_type") == "restrict" + and effective.get("mg_fine_asm_overlap") == asm_overlap + and all( + isinstance(component, Mapping) + and component.get("ksp_type") == "preonly" + and component.get("pc_type") == "lu" + and component.get("factor_solver_type") == "mumps" + for component in subdomains + ) + and coarse.get("ksp_type") == "preonly" + and coarse.get("pc_type") == "lu" + and coarse.get("factor_solver_type") == "mumps" + and transfer_well_formed + and item.get("p_multigrid_operator_checks_passed") is True + ) + + effective_by_frequency = _required_sequence( + solver_configuration.get("effective_by_frequency"), + "solver_configuration.effective_by_frequency", + ) + if list(effective_by_frequency) != [ + {"frequency_hz": expected_frequency, "hierarchy": dict(effective)} + ]: + raise ValueError("fem_smoke effective solver evidence is inconsistent") + + port_solves = _required_sequence(item.get("port_solves"), "port solves") + if len(port_solves) != 2: + raise ValueError("fem_smoke must contain exactly two port solves") + port_names: list[str] = [] + convergence_ok = True + for index, port_value in enumerate(port_solves): + port = _required_mapping(port_value, f"port solve {index}") + port_name = port.get("port_name") + if not isinstance(port_name, str): + raise ValueError("fem_smoke port solve name is missing") + port_names.append(port_name) + iterations = port.get("iterations") + reason = port.get("converged_reason") + if ( + not isinstance(iterations, int) + or isinstance(iterations, bool) + or iterations < 0 + or iterations > maximum_iterations + or not isinstance(reason, int) + or isinstance(reason, bool) + ): + raise ValueError("fem_smoke port iterations/reason are malformed") + residual = _residual_number( + port.get("true_relative_residual"), + f"port {port_name} true relative residual", + ) + residual_norm = _residual_number( + port.get("true_residual_norm"), f"port {port_name} true residual norm" + ) + _finite_number(port.get("solve_seconds"), f"port {port_name} solve_seconds") + history = _required_sequence( + port.get("reported_residual_history"), + f"port {port_name} residual history", + ) + if not history: + raise ValueError("fem_smoke reported residual history is empty") + history_values = [ + _residual_number(history_value, f"port {port_name} residual history") + for history_value in history + ] + convergence_ok = ( + convergence_ok + and reason > 0 + and residual <= maximum_residual + and math.isfinite(residual_norm) + and all(math.isfinite(value) for value in history_values) + ) + if port_names != ["left", "right"]: + raise ValueError("fem_smoke port solve order/identity differs from registration") + + expected_counts = { + "matrix_assemblies": 1, + "preconditioner_matrix_assemblies": 0 if shift == 0.0 else 1, + "coarse_preconditioner_matrix_assemblies": 1, + "transfer_operator_assemblies": 1, + "operator_setups": 1, + "global_numeric_factorizations": 0, + "coarse_global_factorizations": 1, + "rhs_solves": 2, + } + observed_counts: dict[str, int] = {} + for key in expected_counts: + value = payload.get(key) + if not isinstance(value, int) or isinstance(value, bool) or value < 0: + raise ValueError(f"fem_smoke counter {key} is missing or malformed") + observed_counts[key] = value + if payload.get("numeric_factorizations") != observed_counts[ + "global_numeric_factorizations" + ]: + raise ValueError("fem_smoke numeric factorization counter alias is inconsistent") + count_ok = observed_counts == expected_counts + + preconditioner = _required_mapping(payload.get("preconditioner"), "preconditioner") + if ( + preconditioner.get("requested_absorption_shift") != shift + or preconditioner.get("requested_hierarchy") != "p-multigrid" + or preconditioner.get("requested_coarse_degree") != expected_coarse_degree + ): + raise ValueError("fem_smoke requested preconditioner differs from registration") + per_frequency = _required_sequence( + preconditioner.get("per_frequency"), "preconditioner.per_frequency" + ) + expected_preconditioner = { + "frequency_hz": expected_frequency, + "absorption_shift": shift, + "operator_is_physical": expected_physical_preconditioner, + "matrix_nonzeros": item["preconditioner_matrix_nonzeros"], + "matrix_memory_bytes_sum": item.get("preconditioner_matrix_memory_bytes_sum"), + "assembly_seconds": item["preconditioner_assembly_seconds"], + "coarse": { + "degree": expected_coarse_degree, + "global_complex_dofs": coarse_dofs, + "matrix_nonzeros": item["coarse_preconditioner_matrix_nonzeros"], + "matrix_memory_bytes_sum": item.get( + "coarse_preconditioner_matrix_memory_bytes_sum" + ), + "assembly_seconds": item["coarse_preconditioner_assembly_seconds"], + }, + "transfer_operator": dict(transfer), + "p_multigrid_operator_checks_passed": item[ + "p_multigrid_operator_checks_passed" + ], + } + if list(per_frequency) != [expected_preconditioner]: + raise ValueError("fem_smoke preconditioner evidence is inconsistent") + + gates = _required_mapping(payload.get("gates"), "gates") + convergence_gate = _required_mapping( + gates.get("convergence_and_true_residual"), "convergence gate" + ) + count_gate = _required_mapping( + gates.get("assembly_setup_rhs_counts"), "assembly/setup/RHS gate" + ) + pmg_gate = _required_mapping( + gates.get("p_multigrid_structure"), "p-multigrid gate" + ) + expected_pairs = { + "convergence_and_true_residual": convergence_ok, + "assembly_setup_rhs_counts": count_ok, + "p_multigrid_structure": hierarchy_ok, + } + for name, computed in expected_pairs.items(): + gate = _required_mapping(gates.get(name), f"{name} gate") + expected_pair = ("PASSED", True) if computed else ("FAILED", False) + if (gate.get("status"), gate.get("passed")) != expected_pair: + raise ValueError(f"fem_smoke {name} gate differs from recorded evidence") + if convergence_gate.get("maximum_true_relative_residual") != maximum_residual: + raise ValueError("fem_smoke convergence threshold differs from registration") + if ( + count_gate.get("expected") != expected_counts + or count_gate.get("observed") != observed_counts + ): + raise ValueError("fem_smoke assembly/setup/RHS evidence is inconsistent") + if pmg_gate.get("requested_coarse_degree") != expected_coarse_degree: + raise ValueError("fem_smoke p-multigrid gate coarse degree is inconsistent") + return expected_pairs + + +def validate_fem_smoke_artifact( + registration: Mapping[str, Any], + entry: Mapping[str, Any], + payload: Mapping[str, Any], +) -> tuple[str, ...]: + """Fail closed unless a completed FEM artifact matches its registration.""" + + if payload.get("schema") != FEM_SMOKE_SCHEMA: + raise ValueError("fem_smoke schema is missing or unsupported") + observed_source = payload.get("source") + if not isinstance(observed_source, Mapping) or ( + register_scaling_sweep._validated_source(observed_source) + != registration.get("source") + ): + raise ValueError("fem_smoke source identity differs from registration") + images = payload.get("images") + registered_images = registration.get("images") + if not isinstance(images, Mapping) or not isinstance(registered_images, Mapping): + raise ValueError("fem_smoke image identity is missing") + project = registered_images["project_image"] + observed_project = images.get("project_image") + observed_base = images.get("base_image") + if not isinstance(observed_project, Mapping) or not isinstance( + observed_base, Mapping + ): + raise ValueError("fem_smoke image identity is incomplete") + project_field = ( + "local_image_id" if project["kind"] == "local_image_id" else "digest" + ) + if observed_project.get(project_field) != project["identity"]: + raise ValueError("fem_smoke project image differs from registration") + if observed_base.get("digest") != registered_images["base_image"]["identity"]: + raise ValueError("fem_smoke base image differs from registration") + if payload.get("mpi_size") != entry["resource_contract"]["mpi_ranks"]: + raise ValueError("fem_smoke MPI size differs from registration") + command = payload.get("command") + if not isinstance(command, Mapping) or command.get("argv") != entry["command"][4:]: + raise ValueError("fem_smoke command argv differs from registration") + physical = payload.get("physical_problem") + if not isinstance(physical, Mapping) or physical.get("schema") != ( + "scatter3d.validation.physical_problem/v1" + ): + raise ValueError("fem_smoke physical problem identity is missing") + definition = physical.get("definition") + if not isinstance(definition, Mapping): + raise ValueError("fem_smoke physical problem definition is missing") + encoded_definition = json.dumps( + definition, + allow_nan=False, + ensure_ascii=True, + separators=(",", ":"), + sort_keys=True, + ).encode() + if physical.get("sha256") != hashlib.sha256(encoded_definition).hexdigest(): + raise ValueError("fem_smoke physical problem hash is invalid") + if dict(physical) != entry.get("physical_problem"): + raise ValueError("fem_smoke physical problem differs from registration") + + preconditioner = payload.get("preconditioner") + if not isinstance(preconditioner, Mapping): + raise ValueError("fem_smoke preconditioner identity is missing") + requested_shift = preconditioner.get("requested_absorption_shift") + if requested_shift != entry["preconditioner_absorption_shift"]: + raise ValueError("fem_smoke absorption shift differs from registration") + cgroup = payload.get("cgroup_memory") + if not isinstance(cgroup, Mapping) or cgroup.get("limit_bytes") != entry[ + "resource_contract" + ]["cgroup_memory_limit_bytes"]: + raise ValueError("fem_smoke cgroup limit differs from registration") + if ( + cgroup.get("version") != "v2" + or not isinstance(cgroup.get("peak_bytes"), int) + or cgroup["peak_bytes"] <= 0 + or cgroup.get("swap_limit_bytes") != 0 + ): + raise ValueError( + "fem_smoke cgroup v2 peak or no-swap instrumentation is missing" + ) + runtime = payload.get("runtime") + expected_runtime = registered_images["base_image"].get("runtime_metadata") + if not isinstance(runtime, Mapping) or not isinstance( + expected_runtime, Mapping + ): + raise ValueError("fem_smoke runtime identity is missing") + observed_petsc = runtime.get("petsc_version") + if ( + runtime.get("dolfinx_version") != expected_runtime.get("dolfinx") + or runtime.get("mpi4py_version") != expected_runtime.get("mpi4py") + or runtime.get("mpi_library_version") != expected_runtime.get("mpi_library") + or not isinstance(observed_petsc, Sequence) + or ".".join(str(part) for part in observed_petsc) + != expected_runtime.get("petsc") + or "complex128" not in str(runtime.get("petsc_scalar_type")) + ): + raise ValueError("fem_smoke runtime differs from registered image metadata") + gates = payload.get("gates") + if not isinstance(gates, Mapping): + raise ValueError("fem_smoke gates are missing") + outcome_gates = ( + "convergence_and_true_residual", + "assembly_setup_rhs_counts", + "p_multigrid_structure", + "expected_global_dofs", + ) + release_gate = gates.get("release_comparison_provenance") + if ( + not isinstance(release_gate, Mapping) + or release_gate.get("status") != "PASSED" + or release_gate.get("passed") is not True + ): + raise ValueError("fem_smoke release provenance gate did not PASSED") + failed_gates: list[str] = [] + for name in outcome_gates: + gate = gates.get(name) + if not isinstance(gate, Mapping): + raise ValueError(f"fem_smoke registered gate {name} is missing") + pair = (gate.get("status"), gate.get("passed")) + if pair not in {("PASSED", True), ("FAILED", False), ("NOT RUN", None)}: + raise ValueError(f"fem_smoke registered gate {name} is malformed") + if pair == ("FAILED", False): + failed_gates.append(name) + expected_dofs = int(_registered_argument(entry, "--expected-global-dofs")) + dof_gate = gates["expected_global_dofs"] + diagnostics = payload.get("frequency_diagnostics") + if ( + dof_gate.get("expected") != expected_dofs + or not isinstance(diagnostics, Sequence) + or not diagnostics + or any( + not isinstance(item, Mapping) + or item.get("global_complex_dofs") != dof_gate.get("observed") + for item in diagnostics + ) + ): + raise ValueError("fem_smoke exact global DoF evidence differs from registration") + overall = (payload.get("status"), payload.get("passed")) + preflight_failure = ( + payload.get("execution_phase") == "preflight" + and overall == ("FAILED", False) + and failed_gates == ["expected_global_dofs"] + and all( + gates[name].get("status") == "NOT RUN" + for name in outcome_gates + if name != "expected_global_dofs" + ) + ) + if preflight_failure: + expected_frequency = float( + _registered_argument(entry, "--frequencies-hz") + ) + observed_dofs = dof_gate.get("observed") + if ( + dof_gate.get("expected") != expected_dofs + or observed_dofs == expected_dofs + or not isinstance(observed_dofs, int) + or isinstance(observed_dofs, bool) + ): + raise ValueError("preflight fem_smoke exact global DoF failure is inconsistent") + if list(diagnostics) != [ + { + "frequency_hz": expected_frequency, + "global_complex_dofs": observed_dofs, + } + ]: + raise ValueError("preflight fem_smoke diagnostic shape is inconsistent") + completed_fields = { + "solver_configuration", + "matrix_assemblies", + "preconditioner_matrix_assemblies", + "coarse_preconditioner_matrix_assemblies", + "transfer_operator_assemblies", + "operator_setups", + "global_numeric_factorizations", + "coarse_global_factorizations", + "numeric_factorizations", + "rhs_solves", + } + unexpected = sorted(completed_fields.intersection(payload)) + if unexpected: + raise ValueError( + "preflight fem_smoke artifact contains completed-run fields: " + + ", ".join(unexpected) + ) + if "per_frequency" in preconditioner: + raise ValueError( + "preflight fem_smoke artifact contains completed preconditioner evidence" + ) + else: + _validate_completed_fem_evidence(entry, payload) + if any( + gates[name].get("status") == "NOT RUN" for name in outcome_gates + ) and not preflight_failure: + raise ValueError("registered solve outcome gates must not be NOT RUN") + if overall == ("PASSED", True): + if failed_gates or any( + gates[name].get("status") != "PASSED" for name in outcome_gates + ): + raise ValueError("PASSED fem_smoke artifact has an unmet registered gate") + if dof_gate.get("observed") != expected_dofs: + raise ValueError("PASSED fem_smoke artifact has incorrect global DoFs") + elif overall == ("FAILED", False): + if not failed_gates: + raise ValueError("FAILED fem_smoke artifact has no FAILED registered gate") + else: + raise ValueError("fem_smoke overall status is malformed") + return tuple(failed_gates) + + +def _entry_host_paths( + registration: Mapping[str, Any], + entry: Mapping[str, Any], + host_output_root: Path, +) -> dict[str, Path]: + logical_root = Path(str(registration["output_root"])) + paths: dict[str, Path] = {} + for key, value in entry["outputs"].items(): + if key.startswith("container_"): + continue + logical = Path(str(value)) + try: + relative = logical.relative_to(logical_root) + except ValueError as exc: + raise ValueError(f"registered output {key} escapes output_root") from exc + paths[key] = host_output_root / relative + return paths + + +def _sha256_manifest(run_directory: Path, manifest: Path) -> bytes: + lines = [] + for path in sorted(run_directory.iterdir(), key=lambda item: item.name): + if path.is_file() and path != manifest: + digest = hashlib.sha256(path.read_bytes()).hexdigest() + lines.append(f"{digest} {path.name}") + return ("\n".join(lines) + "\n").encode("ascii") + + +def _completed_stdout(completed: subprocess.CompletedProcess[Any]) -> str: + output = completed.stdout or b"" + if isinstance(output, bytes): + return output.decode("utf-8", errors="replace") + return str(output) + + +def _cleanup_container_attempt( + *, + runner: Callable[..., subprocess.CompletedProcess[bytes]], + registration: Mapping[str, Any], + entry: Mapping[str, Any], + container_name: str, + attempt_id: str, + initial_target: str | None, + ambiguous_create: bool, + lifecycle_commands: list[list[str]], + poll_seconds: float, + stable_checks: int, + max_checks: int, +) -> tuple[bool, bool, bytes]: + """Remove only this attempt and prove a stable post-create absence.""" + + if stable_checks < 1 or max_checks < stable_checks or poll_seconds < 0.0: + raise ValueError("container cleanup polling contract is invalid") + attempted = ambiguous_create + diagnostics = b"" + + def remove(target: str) -> None: + nonlocal attempted, diagnostics + attempted = True + command = ["docker", "rm", "--force", target] + lifecycle_commands.append(command) + try: + removed = runner( + command, + check=False, + capture_output=True, + timeout=60, + ) + if int(removed.returncode) != 0: + diagnostics += removed.stderr or b"docker rm failed\n" + except (OSError, subprocess.SubprocessError) as exc: + diagnostics += f"docker rm failed: {exc}\n".encode() + + if initial_target is not None: + remove(initial_target) + + required_stable = stable_checks if ambiguous_create else 1 + checks = max_checks if ambiguous_create else max(2, stable_checks) + consecutive_absent = 0 + for check_index in range(checks): + name_command = [ + "docker", + "ps", + "-aq", + "--no-trunc", + "--filter", + f"name=^/{container_name}$", + ] + attempt_command = [ + "docker", + "ps", + "-aq", + "--no-trunc", + "--filter", + f"label=scatter3d.registration_id={registration['registration_id']}", + "--filter", + f"label=scatter3d.run_id={entry['run_id']}", + "--filter", + f"label=scatter3d.attempt_id={attempt_id}", + ] + observed: list[set[str]] = [] + for command in (name_command, attempt_command): + lifecycle_commands.append(command) + try: + completed = runner( + command, + check=False, + capture_output=True, + timeout=60, + ) + except (OSError, subprocess.SubprocessError) as exc: + diagnostics += f"container cleanup verification failed: {exc}\n".encode() + return attempted, False, diagnostics + if int(completed.returncode) != 0: + diagnostics += completed.stderr or b"container cleanup verification failed\n" + return attempted, False, diagnostics + observed.append( + { + line.strip() + for line in _completed_stdout(completed).splitlines() + if line.strip() + } + ) + name_ids, attempt_ids = observed + foreign_name_ids = name_ids - attempt_ids + if foreign_name_ids: + diagnostics += ( + b"refusing to remove a container name not owned by this executor attempt\n" + ) + return attempted, False, diagnostics + if attempt_ids: + consecutive_absent = 0 + for container_id in sorted(attempt_ids): + remove(container_id) + else: + consecutive_absent += 1 + if not ambiguous_create and consecutive_absent >= required_stable: + return attempted, True, diagnostics + if ambiguous_create and check_index + 1 < checks and poll_seconds: + time.sleep(poll_seconds) + if ambiguous_create and consecutive_absent >= required_stable: + return attempted, True, diagnostics + diagnostics += b"container attempt did not reach the stable absence window\n" + return attempted, False, diagnostics + + +def execute_registered_entries( + registration: Mapping[str, Any], + *, + image_reference: str, + output_parent: Path, + runner: Callable[..., subprocess.CompletedProcess[bytes]] = subprocess.run, + cgroup_resolver: Callable[[int], Path] = _resolve_host_cgroup, + cgroup_reader: Callable[[Path], dict[str, Any]] = _read_cgroup_v2_metrics, + cgroup_populated: Callable[[Path], bool] = _cgroup_is_populated, + timeout_seconds: float | None = None, + run_ids: Sequence[str] | None = None, + cleanup_poll_seconds: float = 1.0, + cleanup_stable_checks: int = 10, + cleanup_max_checks: int = 60, +) -> list[dict[str, Any]]: + """Run selected entries, preserving evidence and stopping on non-passing evidence.""" + host_output_root = output_parent.expanduser().resolve() / Path( + str(registration["output_root"]) + ) + all_entries = registration["entries"] + available_ids = {entry["run_id"] for entry in all_entries} + selected_ids = available_ids if run_ids is None else set(run_ids) + unknown_ids = sorted(selected_ids - available_ids) + if unknown_ids: + raise ValueError(f"unregistered run ids requested: {unknown_ids}") + entries = [entry for entry in all_entries if entry["run_id"] in selected_ids] + if not entries: + raise ValueError("at least one registered run id must be selected") + planned: list[tuple[Mapping[str, Any], dict[str, Path]]] = [] + for entry in entries: + registered_timeout = entry["resource_contract"].get( + "wall_time_limit_seconds" + ) + if ( + not isinstance(registered_timeout, int) + or isinstance(registered_timeout, bool) + or registered_timeout <= 0 + ): + raise ValueError("registered wall-time limit must be a positive integer") + if timeout_seconds is not None and timeout_seconds != registered_timeout: + raise ValueError("executor timeout differs from registered wall-time limit") + paths = _entry_host_paths(registration, entry, host_output_root) + if paths["directory"].exists(): + raise FileExistsError( + f"refusing to reuse registered run directory: {paths['directory']}" + ) + planned.append((entry, paths)) + host_output_root.mkdir(parents=True, exist_ok=True) + summaries: list[dict[str, Any]] = [] + for entry, paths in planned: + started_monotonic = time.monotonic() + registered_timeout = int( + entry["resource_contract"]["wall_time_limit_seconds"] + ) + run_directory = paths["directory"] + run_directory.mkdir(parents=False, exist_ok=False) + if os.name == "posix": + run_directory.chmod(0o777) + ready_path = run_directory / ".executor-ready" + go_path = run_directory / ".executor-go" + solver_done_path = run_directory / ".solver-done" + solver_exit_path = run_directory / ".solver-exit-code" + collected_path = run_directory / ".executor-collected" + attempt_id = secrets.token_hex(16) + create_command = build_docker_create_command( + registration, + entry, + image_reference=image_reference, + host_run_directory=run_directory, + attempt_id=attempt_id, + ) + stdout = b"" + stderr = b"" + return_code: int | None = None + solver_exit_code: int | None = None + launch_prevented = False + timed_out = False + lifecycle_error: str | None = None + container_id: str | None = None + container_name = create_command[create_command.index("--name") + 1] + create_ambiguous = False + lifecycle_stage = "create" + state: Mapping[str, Any] | None = None + host_config: Mapping[str, Any] | None = None + cgroup_path: Path | None = None + cgroup_metrics: dict[str, Any] | None = None + cgroup_error: str | None = None + lifecycle_commands: list[list[str]] = [create_command] + cleanup_attempted = False + cleanup_succeeded: bool | None = None + cleanup_absence_verified: bool | None = None + try: + created = runner( + create_command, check=False, capture_output=True, timeout=60 + ) + if int(created.returncode) != 0: + launch_prevented = True + stderr += created.stderr or b"docker create failed\n" + else: + raw_container_id = (created.stdout or b"").decode( + "utf-8", errors="replace" + ).strip() + if not raw_container_id: + launch_prevented = True + create_ambiguous = True + stderr += b"docker create returned no container id\n" + else: + container_id = raw_container_id.splitlines()[-1] + lifecycle_stage = "start" + start_command = ["docker", "start", container_id] + lifecycle_commands.append(start_command) + started = runner( + start_command, check=False, capture_output=True, timeout=60 + ) + if int(started.returncode) != 0: + launch_prevented = True + stderr += started.stderr or b"docker start failed\n" + else: + barrier_deadline = time.monotonic() + 60.0 + while not ready_path.is_file() and time.monotonic() < barrier_deadline: + time.sleep(0.05) + if not ready_path.is_file(): + lifecycle_error = "container startup barrier was not reached" + inspect_command = [ + "docker", + "inspect", + "--format", + "{{json .}}", + container_id, + ] + lifecycle_commands.append(inspect_command) + inspected = runner( + inspect_command, + check=False, + capture_output=True, + timeout=60, + ) + if int(inspected.returncode) != 0: + lifecycle_error = "initial docker inspect failed" + stderr += inspected.stderr or b"initial docker inspect failed\n" + else: + try: + initial_state, _ = _parse_container_inspection( + inspected.stdout or b"" + ) + if initial_state["Running"] is not True: + raise ValueError( + "container exited before executor released barrier" + ) + cgroup_path = cgroup_resolver( + int(initial_state["Pid"]) + ) + except (OSError, ValueError) as exc: + lifecycle_error = str(exc) + if lifecycle_error is None: + go_path.touch(exist_ok=False) + if os.name == "posix": + go_path.chmod(0o666) + solve_deadline = time.monotonic() + registered_timeout + while True: + try: + cgroup_metrics = cgroup_reader(cgroup_path) + cgroup_error = None + except (OSError, ValueError) as exc: + if cgroup_metrics is None: + cgroup_error = str(exc) + if solver_done_path.is_file(): + break + try: + if not cgroup_populated(cgroup_path): + lifecycle_error = ( + "container cgroup became empty before solver-done barrier" + ) + break + except (OSError, ValueError) as exc: + lifecycle_error = str(exc) + break + if time.monotonic() >= solve_deadline: + timed_out = True + break + time.sleep(0.25) + if solver_done_path.is_file(): + try: + solver_exit_code = int( + solver_exit_path.read_text( + encoding="ascii" + ).strip() + ) + except (OSError, ValueError) as exc: + lifecycle_error = ( + f"solver exit-code barrier is invalid: {exc}" + ) + try: + cgroup_metrics = cgroup_reader(cgroup_path) + cgroup_error = None + except (OSError, ValueError) as exc: + cgroup_error = str(exc) + collected_path.touch(exist_ok=False) + if os.name == "posix": + collected_path.chmod(0o666) + wait_command = ["docker", "wait", container_id] + lifecycle_commands.append(wait_command) + waited = runner( + wait_command, + check=False, + capture_output=True, + timeout=60, + ) + if int(waited.returncode) != 0: + lifecycle_error = ( + lifecycle_error or "docker wait failed" + ) + stderr += waited.stderr or b"docker wait failed\n" + else: + kill_command = ["docker", "kill", container_id] + lifecycle_commands.append(kill_command) + killed = runner( + kill_command, + check=False, + capture_output=True, + timeout=60, + ) + if int(killed.returncode) != 0: + stderr += killed.stderr or b"docker kill failed\n" + reap_command = ["docker", "wait", container_id] + lifecycle_commands.append(reap_command) + reaped = runner( + reap_command, + check=False, + capture_output=True, + timeout=60, + ) + if int(reaped.returncode) != 0: + stderr += reaped.stderr or b"docker wait failed\n" + if cgroup_path is None and not launch_prevented: + cgroup_error = "host cgroup-v2 path was not captured" + logs_command = ["docker", "logs", container_id] + lifecycle_commands.append(logs_command) + logs = runner( + logs_command, + check=False, + capture_output=True, + timeout=60, + ) + stdout += logs.stdout or b"" + stderr += logs.stderr or b"" + final_inspect = [ + "docker", + "inspect", + "--format", + "{{json .}}", + container_id, + ] + lifecycle_commands.append(final_inspect) + inspected = runner( + final_inspect, + check=False, + capture_output=True, + timeout=60, + ) + if int(inspected.returncode) != 0: + lifecycle_error = lifecycle_error or "final docker inspect failed" + stderr += inspected.stderr or b"final docker inspect failed\n" + else: + try: + state, host_config = _parse_container_inspection( + inspected.stdout or b"" + ) + return_code = ( + None if state["Running"] else int(state["ExitCode"]) + ) + if ( + solver_exit_code is not None + and return_code != solver_exit_code + ): + raise ValueError( + "solver barrier exit code differs from Docker state" + ) + except ValueError as exc: + lifecycle_error = lifecycle_error or str(exc) + except subprocess.TimeoutExpired as exc: + if container_id is None: + launch_prevented = True + create_ambiguous = lifecycle_stage == "create" + else: + timed_out = True + stdout += exc.stdout or b"" + stderr += exc.stderr or b"" + except (OSError, ValueError) as exc: + if container_id is None: + launch_prevented = True + else: + lifecycle_error = str(exc) + stderr += f"{type(exc).__name__}: {exc}\n".encode() + finally: + try: + ( + cleanup_attempted, + cleanup_absence_verified, + cleanup_diagnostics, + ) = _cleanup_container_attempt( + runner=runner, + registration=registration, + entry=entry, + container_name=container_name, + attempt_id=attempt_id, + initial_target=container_id, + ambiguous_create=create_ambiguous, + lifecycle_commands=lifecycle_commands, + poll_seconds=cleanup_poll_seconds, + stable_checks=cleanup_stable_checks, + max_checks=cleanup_max_checks, + ) + stderr += cleanup_diagnostics + cleanup_succeeded = cleanup_absence_verified + except ValueError as exc: + cleanup_succeeded = False + cleanup_absence_verified = False + stderr += f"container cleanup contract failed: {exc}\n".encode() + ready_path.unlink(missing_ok=True) + go_path.unlink(missing_ok=True) + solver_done_path.unlink(missing_ok=True) + solver_exit_path.unlink(missing_ok=True) + collected_path.unlink(missing_ok=True) + _publish_bytes(stdout, paths["stdout_log"]) + _publish_bytes(stderr, paths["stderr_log"]) + fem_payload = None + fem_validation_error = None + fem_failed_gates: tuple[str, ...] = () + fem_path = paths["fem_smoke_json"] + if fem_path.exists(): + mode = fem_path.lstat().st_mode + if fem_path.is_symlink() or not stat.S_ISREG(mode): + fem_validation_error = ( + "fem_smoke artifact must be a regular non-symlink file" + ) + elif os.name == "posix" and stat.S_IMODE(mode) != 0o644: + fem_validation_error = "fem_smoke artifact mode must be exactly 0644" + if fem_validation_error is None and fem_path.is_file(): + try: + candidate = json.loads(fem_path.read_bytes()) + if isinstance(candidate, Mapping): + fem_payload = candidate + else: + fem_validation_error = "fem_smoke artifact must be a JSON object" + except json.JSONDecodeError: + fem_validation_error = "fem_smoke artifact is malformed JSON" + if fem_payload is not None: + try: + fem_failed_gates = validate_fem_smoke_artifact( + registration, entry, fem_payload + ) + except ValueError as exc: + fem_validation_error = str(exc) + evidence_consistent = True + if fem_path.exists(): + evidence_consistent = bool( + fem_validation_error is None + and fem_payload is not None + and ( + ( + fem_payload.get("status") == "PASSED" + and fem_payload.get("passed") is True + and return_code == 0 + ) + or ( + fem_payload.get("status") == "FAILED" + and fem_payload.get("passed") is False + and return_code == 1 + ) + ) + ) + status, passed, reason = classify_run_result( + docker_return_code=return_code, + fem_payload=fem_payload, + launch_prevented=launch_prevented, + ) + if fem_validation_error is not None: + status = "FAILED" + passed = False + reason = fem_validation_error + elif fem_failed_gates: + reason = "fem_smoke FAILED gates: " + ", ".join(fem_failed_gates) + if lifecycle_error is not None: + status = "FAILED" + passed = False + reason = lifecycle_error + if state is not None and ( + state.get("Running") is True + or state.get("OOMKilled") is True + or not isinstance(state.get("FinishedAt"), str) + or not state["FinishedAt"] + or state["FinishedAt"].startswith("0001-") + ): + status = "FAILED" + passed = False + reason = "Docker container did not finish cleanly" + requested_memory = entry["resource_contract"]["cgroup_memory_limit_bytes"] + resource_contract_passed = bool( + host_config is not None + and host_config.get("Memory") == requested_memory + and host_config.get("MemorySwap") == requested_memory + and cgroup_metrics is not None + and cgroup_metrics.get("limit_bytes") == requested_memory + and cgroup_metrics.get("swap_limit_bytes") == 0 + ) + if state is not None and not resource_contract_passed: + status = "FAILED" + passed = False + reason = "Docker memory/no-swap resource contract was not applied" + if state is not None and state.get("OOMKilled") is True: + status = "FAILED" + passed = False + reason = "Docker cgroup OOMKilled the registered solve" + elif return_code == 137: + status = "FAILED" + passed = False + reason = "registered solve exited 137 under the cgroup limit" + if ( + cgroup_error is not None + and not launch_prevented + and lifecycle_error is None + ): + status = "FAILED" + passed = False + reason = f"cgroup instrumentation FAILED: {cgroup_error}" + if cgroup_metrics is not None and any( + cgroup_metrics["events"].get(name, 0) > 0 for name in ("oom", "oom_kill") + ): + status = "FAILED" + passed = False + reason = "host cgroup memory.events recorded OOM activity" + if timed_out: + status = "FAILED" + passed = False + reason = "registered solve exceeded the executor timeout" + if cleanup_succeeded is not True: + status = "FAILED" + passed = False + reason = "container cleanup or post-removal absence verification FAILED" + result = { + "schema": RESULT_SCHEMA, + "registration_id": registration["registration_id"], + "run_id": entry["run_id"], + "executor_attempt_id": attempt_id, + "status": status, + "passed": passed, + "docker_return_code": return_code, + "solver_barrier_exit_code": solver_exit_code, + "launch_prevented": launch_prevented, + "timed_out": timed_out, + "wall_time_limit_seconds": registered_timeout, + "executor_wall_seconds": time.monotonic() - started_monotonic, + "container_state": dict(state) if state is not None else None, + "container_host_config": ( + { + "Memory": host_config.get("Memory"), + "MemorySwap": host_config.get("MemorySwap"), + } + if host_config is not None + else None + ), + "container_resource_contract_passed": resource_contract_passed, + "host_cgroup": cgroup_metrics, + "host_cgroup_error": cgroup_error, + "container_cleanup_attempted": cleanup_attempted, + "container_cleanup_succeeded": cleanup_succeeded, + "container_absence_verified": cleanup_absence_verified, + "fem_smoke_artifact_present": paths["fem_smoke_json"].is_file(), + "fem_smoke_failed_gates": list(fem_failed_gates), + "evidence_consistent": evidence_consistent, + "reason": reason, + "docker_lifecycle_commands": lifecycle_commands, + } + _publish_json(result, paths["exit_code_json"]) + _publish_bytes( + _sha256_manifest(run_directory, paths["sha256_manifest"]), + paths["sha256_manifest"], + ) + if os.name == "posix": + run_directory.chmod(0o755) + summaries.append(result) + if not evidence_consistent: + raise RuntimeError( + "inconsistent fem_smoke/Docker evidence was preserved; " + "refusing to launch another registered run" + ) + if status != "PASSED": + break + return summaries + + +def _docker_json( + command: Sequence[str], + *, + runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run, +) -> Any: + try: + completed = runner( + list(command), check=True, capture_output=True, text=True, timeout=60 + ) + except (FileNotFoundError, subprocess.SubprocessError, OSError) as exc: + raise ValueError(f"Docker identity inspection failed: {exc}") from exc + try: + return json.loads(completed.stdout) + except json.JSONDecodeError as exc: + raise ValueError("Docker identity inspection returned malformed JSON") from exc + + +def inspect_docker_image(image_reference: str) -> dict[str, Any]: + payload = _docker_json(("docker", "image", "inspect", image_reference)) + if not isinstance(payload, list) or len(payload) != 1 or not isinstance(payload[0], Mapping): + raise ValueError("Docker image inspection must return exactly one image") + return dict(payload[0]) + + +def read_image_runtime_metadata(image_reference: str) -> dict[str, Any]: + payload = _docker_json( + ( + "docker", + "run", + "--rm", + "--entrypoint", + "cat", + image_reference, + "/opt/scatter3d/runtime-metadata.json", + ) + ) + if not isinstance(payload, Mapping): + raise ValueError("image runtime metadata must be a JSON object") + return dict(payload) + + +def main() -> int: + repository_default = Path(__file__).resolve().parents[1] + parser = argparse.ArgumentParser() + parser.add_argument("--registration", type=Path, required=True) + parser.add_argument( + "--spec", + type=Path, + default=Path(__file__).with_name("scaling_sweep_v1.json"), + ) + parser.add_argument("--repository", type=Path, default=repository_default) + parser.add_argument("--image", required=True) + parser.add_argument("--output-parent", type=Path, required=True) + parser.add_argument("--timeout-seconds", type=float) + parser.add_argument( + "--run-id", + action="append", + dest="run_ids", + help="execute only this registered run id; repeat for multiple entries", + ) + args = parser.parse_args() + try: + if not args.registration.is_file(): + raise ValueError("complete registration file does not exist") + if os.name == "posix" and stat.S_IMODE(args.registration.stat().st_mode) != 0o644: + raise ValueError("registration file mode must be exactly 0644") + registration, registration_bytes = _read_json_object( + args.registration, "registration" + ) + spec, spec_bytes = _read_json_object(args.spec, "scaling sweep specification") + source = register_scaling_sweep._git_source(args.repository) + image = inspect_docker_image(args.image) + immutable_image_id = validate_image_inspection(registration, image) + runtime_metadata = read_image_runtime_metadata(immutable_image_id) + validate_execution_context( + registration, + spec=spec, + spec_bytes=spec_bytes, + source=source, + runtime_metadata=runtime_metadata, + ) + summaries = execute_registered_entries( + registration, + image_reference=immutable_image_id, + output_parent=args.output_parent, + timeout_seconds=args.timeout_seconds, + run_ids=args.run_ids, + ) + if args.registration.read_bytes() != registration_bytes: + raise RuntimeError("registration changed during execution") + except ( + FileExistsError, + FileNotFoundError, + json.JSONDecodeError, + RuntimeError, + ValueError, + ) as exc: + parser.error(str(exc)) + print(json.dumps(summaries, indent=2, sort_keys=True, allow_nan=False)) + return 0 if all(item["status"] == "PASSED" for item in summaries) else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/validation/scaling_sweep_v1.json b/validation/scaling_sweep_v1.json new file mode 100644 index 0000000..1be899f --- /dev/null +++ b/validation/scaling_sweep_v1.json @@ -0,0 +1,57 @@ +{ + "schema": "scatter3d.validation.scaling_sweep/v1", + "canonical_path": "validation/scaling_sweep_v1.json", + "status_vocabulary": [ + "PASSED", + "FAILED", + "NOT RUN", + "BLOCKED" + ], + "physical_problem": { + "fine_degree": 3, + "coarse_degree": 1, + "frequency_hz": 100000000.0, + "ports": [ + "left", + "right" + ] + }, + "solver": { + "hierarchy": "p-multigrid", + "outer_ksp": "fgmres", + "preconditioning_side": "right", + "maximum_true_relative_residual": 1e-07, + "maximum_iterations": 1000, + "asm_overlap": 1, + "fine_local_ksp": "preonly", + "fine_local_pc": "lu", + "fine_local_factor_solver": "mumps", + "coarse_ksp": "preonly", + "coarse_pc": "lu", + "coarse_factor_solver": "mumps" + }, + "preconditioner_absorption_shifts": [ + 0.0, + 0.25, + 0.5, + 1.0 + ], + "cgroup_memory_limit_bytes": 30064771072, + "wall_time_limit_seconds": 10800, + "rungs": [ + { + "id": "p3-n9-mpi4", + "subdivisions": 9, + "mpi_ranks": 4, + "gmres_restart": 80, + "expected_global_complex_dofs": 86103 + }, + { + "id": "p3-n16-mpi8", + "subdivisions": 16, + "mpi_ranks": 8, + "gmres_restart": 100, + "expected_global_complex_dofs": 470928 + } + ] +}