diff --git a/.github/workflows/native-cpu-contract.yml b/.github/workflows/native-cpu-contract.yml new file mode 100644 index 000000000..3e65f8344 --- /dev/null +++ b/.github/workflows/native-cpu-contract.yml @@ -0,0 +1,181 @@ +name: Native CPU source contracts + +on: + push: + branches: + - feature/native-cpu-inference + - feature/native-coding-output-constraint + - fix/native-session-context-capacity + - fix/native-q1-aligned-load + paths: + - workers/volparossa-ml/native-cpu/** + - workers/volparossa-ml/build_llama_cpu.py + - workers/volparossa-ml/convert_llama_cpu.py + - workers/volparossa-ml/llama_cpu.py + - workers/volparossa-ml/test_native_session.py + - workers/volparossa-ml/test_native_cpu_alignment.py + - .github/workflows/native-cpu-contract.yml + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: native-cpu-contract-${{ github.ref }} + cancel-in-progress: false + +jobs: + native: + name: Source-only ${{ matrix.mode }} + runs-on: ubuntu-24.04 + timeout-minutes: 40 + strategy: + fail-fast: false + matrix: + mode: [normal, asan-ubsan] + env: + PYTHONDONTWRITEBYTECODE: '1' + NATIVE_MODE: ${{ matrix.mode }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Require disposable hosted runner and exact source + run: | + set -euo pipefail + test "$GITHUB_ACTIONS" = true + test "$RUNNER_ENVIRONMENT" = github-hosted + test "$RUNNER_OS" = Linux + test "$GITHUB_REPOSITORY" = VOLPAROSSA/volparossa + test "$GITHUB_WORKFLOW_SHA" = "$GITHUB_SHA" + test "$(git rev-parse HEAD)" = "$GITHUB_SHA" + test -z "$(git status --porcelain --untracked-files=all)" + env -i PATH=/usr/local/bin:/usr/bin:/bin /bin/bash -euo pipefail -c \ + 'for tool in cmake c++ cc git readelf nice python3; do command -v "$tool"; done' + mkdir -p build/native-contract + python3 -B - <<'PY' + import hashlib, json, os, subprocess + from pathlib import Path + + def git(*args): + return subprocess.check_output(['git', *args]) + + head = os.environ['GITHUB_SHA'] + paths = ( + 'workers/volparossa-ml/build_llama_cpu.py', + 'workers/volparossa-ml/convert_llama_cpu.py', + 'workers/volparossa-ml/llama_cpu.py', + 'workers/volparossa-ml/native-cpu/CMakeLists.txt', + 'workers/volparossa-ml/native-cpu/q1-aligned-load.patch', + 'workers/volparossa-ml/native-cpu/abi-smoke.cpp', + 'workers/volparossa-ml/native-cpu/adapter.cpp', + 'workers/volparossa-ml/native-cpu/adapter.h', + 'workers/volparossa-ml/native-cpu/session-state.h', + 'workers/volparossa-ml/native-cpu/session-tests.cpp', + 'workers/volparossa-ml/test_native_session.py', + 'workers/volparossa-ml/test_native_cpu_alignment.py', + ) + files = {} + for name in paths: + raw = git('show', head + ':' + name) + assert Path(name).read_bytes() == raw + files[name] = dict(bytes=len(raw), sha256=hashlib.sha256(raw).hexdigest(), + blob=git('rev-parse', head + ':' + name).decode().strip()) + workflow = '.github/workflows/native-cpu-contract.yml' + record = dict(version=1, kind='native-cpu-contract-source', + source_commit=head, source_tree=git('rev-parse', 'HEAD^{tree}').decode().strip(), + workflow_commit=os.environ['GITHUB_WORKFLOW_SHA'], workflow_path=workflow, + workflow_sha256=hashlib.sha256(git('show', head + ':' + workflow)).hexdigest(), + mode=os.environ['NATIVE_MODE'], run_id=os.environ['GITHUB_RUN_ID'], + run_attempt=os.environ['GITHUB_RUN_ATTEMPT'], files=files, + model_execution=False, real_kv_memory_saving_proven=False) + with Path('build/native-contract/source.json').open('x') as stream: + stream.write(json.dumps(record, sort_keys=True) + '\n') + PY + - name: Check the same 98 inert Python cases in each matrix mode + run: | + set -euo pipefail + ( + cd workers/volparossa-ml + timeout --kill-after=5s 120s \ + env -i PATH=/usr/local/bin:/usr/bin:/bin LANG=C.UTF-8 PYTHONDONTWRITEBYTECODE=1 \ + python3 -B -m unittest -v test_llama_cpu test_conversion_diagnostic \ + test_native_output_constraint test_native_source_lifecycle test_native_session \ + test_native_cpu_alignment + ) 2>&1 | tee build/native-contract/python-contracts.log + python3 -B - <<'PY' + import json, os, re + from pathlib import Path + text = Path('build/native-contract/python-contracts.log').read_text() + cases = re.findall(r'^(test_[A-Za-z0-9_]+) \(([^)]+)\) \.\.\. ok$', text, re.M) + names = [scope + '::' + method for method, scope in cases] + assert len(names) == len(set(names)) == 98 + assert re.findall(r'^Ran (\d+) tests in .+$', text, re.M) == ['98'] + assert re.findall(r'^OK.*$', text, re.M) == ['OK'] + record = dict(version=1, mode=os.environ['NATIVE_MODE'], passed=98, failed=0, + skipped=0, unique_tests_across_matrix=98, repeated_in_each_matrix_mode=True, + test_names=sorted(names), model_execution=False) + with Path('build/native-contract/python-contracts.json').open('x') as stream: + stream.write(json.dumps(record, sort_keys=True) + '\n') + PY + - name: Fetch the pinned upstream source only + run: | + set -euo pipefail + mkdir -p build/native-contract + git init build/native-contract/llama + git -C build/native-contract/llama remote add origin https://github.com/ggml-org/llama.cpp.git + git -C build/native-contract/llama fetch --depth=1 origin 7fe450e19305b828c199d602c23a8337aaa1f03b + git -C build/native-contract/llama checkout --detach 7fe450e19305b828c199d602c23a8337aaa1f03b + test "$(git -C build/native-contract/llama rev-parse HEAD)" = 7fe450e19305b828c199d602c23a8337aaa1f03b + - name: Compile and run bounded native and upstream checks without models + run: | + set -euo pipefail + flags=() + if test "$NATIVE_MODE" = asan-ubsan; then flags+=(--sanitize); fi + env -i PATH=/usr/local/bin:/usr/bin:/bin LANG=C.UTF-8 PYTHONDONTWRITEBYTECODE=1 \ + python3 -B workers/volparossa-ml/build_llama_cpu.py \ + --source "$PWD/build/native-contract/llama" \ + --output "$PWD/build/native-contract/output" --execute --checks "${flags[@]}" + - name: Recheck original source after success or failure + if: always() + run: | + set -euo pipefail + mkdir -p build/native-contract + ( + test "$(git rev-parse HEAD)" = "$GITHUB_SHA" + test "$GITHUB_WORKFLOW_SHA" = "$GITHUB_SHA" + git diff --exit-code HEAD + # Only the predeclared evidence/upstream/build directory is untracked output. + test -z "$(git status --porcelain --untracked-files=all -- . ':(exclude)build/native-contract')" + python3 -B - <<'PY' + import hashlib, json, os, subprocess + from pathlib import Path + record = json.loads(Path('build/native-contract/source.json').read_text()) + assert record['source_commit'] == os.environ['GITHUB_SHA'] + assert record['workflow_commit'] == os.environ['GITHUB_WORKFLOW_SHA'] + assert record['source_tree'] == subprocess.check_output( + ['git', 'rev-parse', 'HEAD^{tree}']).decode().strip() + for name, expected in record['files'].items(): + raw = Path(name).read_bytes() + assert len(raw) == expected['bytes'] + assert hashlib.sha256(raw).hexdigest() == expected['sha256'] + assert hashlib.sha256(Path(record['workflow_path']).read_bytes()).hexdigest() == record['workflow_sha256'] + print('NATIVE_SOURCE_UNCHANGED') + PY + ) 2>&1 | tee build/native-contract/source-check.log + - name: Retain original source-only reports and logs, including failures + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: native-source-${{ matrix.mode }}-${{ github.sha }} + path: | + build/native-contract/source.json + build/native-contract/source-check.log + build/native-contract/python-contracts.json + build/native-contract/python-contracts.log + build/native-contract/output/build.json + build/native-contract/output/build.log + build/native-contract/output/checks.log + if-no-files-found: error + retention-days: 14 diff --git a/.github/workflows/native-session-diagnostics.yml b/.github/workflows/native-session-diagnostics.yml new file mode 100644 index 000000000..59cfbf849 --- /dev/null +++ b/.github/workflows/native-session-diagnostics.yml @@ -0,0 +1,292 @@ +name: Native session closed diagnostics +run-name: Exact-source native session diagnostics without a model + +on: + workflow_dispatch: + push: + branches: [feature/native-session-closed-errors] + paths: + - .github/workflows/native-session-diagnostics.yml + - crates/volparossa/src/compute/supervise/diagnostic.rs + - crates/volparossa/src/compute/supervise/native_session.rs + - crates/volparossa/src/compute/supervise/native_session/tests.rs + - crates/volparossa/src/compute/supervise/native_session/worker.rs + - tests/integration/agent-private-conversation.py + - tests/integration/test-agent-private-conversation.py + +permissions: + contents: read + +concurrency: + group: native-session-diagnostics-${{ github.ref }} + cancel-in-progress: false + +defaults: + run: + shell: bash + +env: + BASELINE_COMMIT: 0de65f423a6d96fa565facf6f283efae70adf2bd + CARGO_BUILD_JOBS: '2' + CARGO_TERM_COLOR: never + PYTHONDONTWRITEBYTECODE: '1' + RUSTUP_TOOLCHAIN: 1.85.0 + +jobs: + diagnostics: + runs-on: ubuntu-24.04 + timeout-minutes: 25 + steps: + - name: Check out the exact candidate + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: Bind the source and reject changes outside this diagnostics slice + id: scope + run: | + set -euo pipefail + test "$GITHUB_ACTIONS" = true + test "$RUNNER_ENVIRONMENT" = github-hosted + test "$RUNNER_OS" = Linux + test "$RUNNER_ARCH" = X64 + test "$(id -u)" != 0 + test "$GITHUB_REPOSITORY" = VOLPAROSSA/volparossa + test "$GITHUB_REF" = refs/heads/feature/native-session-closed-errors + test "$GITHUB_WORKFLOW_SHA" = "$GITHUB_SHA" + test "$(git rev-parse HEAD)" = "$GITHUB_SHA" + test -z "$(git status --porcelain --untracked-files=all)" + timeout --kill-after=10s 120s git fetch --no-tags --depth=1 origin "$BASELINE_COMMIT" + umask 077 + output="$RUNNER_TEMP/native-session-diagnostics" + target="$RUNNER_TEMP/native-session-diagnostics-target" + test ! -e "$output" + test ! -e "$target" + install -d -m 0700 "$output" "$target" + printf 'DIAGNOSTIC_OUTPUT=%s\nCARGO_TARGET_DIR=%s\n' "$output" "$target" >> "$GITHUB_ENV" + python3 -B - "$output/source.json" <<'PY' + import hashlib, json, os, subprocess, sys + from pathlib import Path + + def git(*args): + return subprocess.check_output(['git', *args]) + + baseline = os.environ['BASELINE_COMMIT'] + head = os.environ['GITHUB_SHA'] + paths = { + '.github/workflows/native-session-diagnostics.yml': 'A', + 'crates/volparossa/src/compute/supervise/diagnostic.rs': 'M', + 'crates/volparossa/src/compute/supervise/native_session.rs': 'M', + 'crates/volparossa/src/compute/supervise/native_session/tests.rs': 'M', + 'crates/volparossa/src/compute/supervise/native_session/worker.rs': 'M', + 'tests/integration/agent-private-conversation.py': 'M', + 'tests/integration/test-agent-private-conversation.py': 'M', + } + changed = git('diff', '--no-renames', '--name-status', baseline, head).decode().splitlines() + assert sorted(changed) == sorted(f'{status}\t{path}' for path, status in paths.items()) + files = {} + for path in sorted(paths): + entry = git('ls-tree', head, '--', path).decode().strip() + assert entry.startswith('100644 blob ') and entry.endswith('\t' + path) + raw = git('show', f'{head}:{path}') + files[path] = {'bytes': len(raw), 'sha256': hashlib.sha256(raw).hexdigest()} + value = dict(version=1, candidate_commit=head, + workflow_commit=os.environ['GITHUB_WORKFLOW_SHA'], + candidate_tree=git('rev-parse', f'{head}^{{tree}}').decode().strip(), + baseline_commit=baseline, + baseline_tree=git('rev-parse', f'{baseline}^{{tree}}').decode().strip(), + run_id=os.environ['GITHUB_RUN_ID'], run_attempt=os.environ['GITHUB_RUN_ATTEMPT'], + files=files, other_tracked_source_unchanged=True, + model_execution=False, native_build=False, vm_execution=False, + original_actual_failure_cause_proven=False) + Path(sys.argv[1]).write_text(json.dumps(value, sort_keys=True) + '\n') + PY + + - name: Install the pinned source-check toolchain + id: toolchain + run: | + set -euo pipefail + rustup toolchain install 1.85.0 --profile minimal --no-self-update \ + --component clippy --component rustfmt + rustc +1.85.0 --version --verbose + cargo +1.85.0 --version --verbose + + - name: Install the reviewed Protobuf compiler on this disposable runner + run: | + set -euo pipefail + test "$GITHUB_ACTIONS" = true + test "$RUNNER_ENVIRONMENT" = github-hosted + test "$(dpkg --print-architecture)" = amd64 + sudo -n apt-get update + sudo -n apt-get install --yes --no-install-recommends \ + protobuf-compiler=3.21.12-8.2ubuntu0.3 + test "$(protoc --version)" = 'libprotoc 3.21.12' + + - name: Check the closed Python collector contracts + id: python + run: | + set -euo pipefail + set +e + timeout --kill-after=5s 90s python3 -B tests/integration/test-agent-private-conversation.py \ + 2>&1 | tee "$DIAGNOSTIC_OUTPUT/python.log" + statuses=("${PIPESTATUS[@]}") + set -e + printf '%s\n' "${statuses[0]}" > "$DIAGNOSTIC_OUTPUT/python.status" + test "${statuses[1]}" -eq 0 + test "${statuses[0]}" -eq 0 + python3 -B - "$DIAGNOSTIC_OUTPUT/python.log" <<'PY' + import re, sys + from pathlib import Path + text = Path(sys.argv[1]).read_text() + assert re.findall(r'^Ran (\d+) tests in .+$', text, re.M) == ['32'] + assert re.findall(r'^OK.*$', text, re.M) == ['OK'] + PY + + - name: Fetch locked dependencies before offline checks + id: fetch + run: | + set -euo pipefail + timeout --kill-after=10s 300s cargo +1.85.0 fetch --locked + git diff --exit-code + + - name: Inventory and execute only the three native diagnostic test groups + id: rust + run: | + set -euo pipefail + for group in diagnostic native_session private_serve; do + case "$group" in + diagnostic) filter=compute::supervise::diagnostic:: ;; + native_session) filter=compute::supervise::native_session:: ;; + private_serve) filter=compute::private_serve::tests::native_session:: ;; + esac + set +e + timeout --kill-after=10s 900s cargo +1.85.0 test --locked --offline \ + -p volparossa --bin volparossa "$filter" -- --list --format terse \ + 2>&1 | tee "$DIAGNOSTIC_OUTPUT/$group.inventory.log" + statuses=("${PIPESTATUS[@]}") + set -e + printf '%s\n' "${statuses[0]}" > "$DIAGNOSTIC_OUTPUT/$group.inventory.status" + test "${statuses[1]}" -eq 0 + test "${statuses[0]}" -eq 0 + set +e + timeout --kill-after=10s 180s cargo +1.85.0 test --locked --offline \ + -p volparossa --bin volparossa "$filter" -- --color never \ + 2>&1 | tee "$DIAGNOSTIC_OUTPUT/$group.tests.log" + statuses=("${PIPESTATUS[@]}") + set -e + printf '%s\n' "${statuses[0]}" > "$DIAGNOSTIC_OUTPUT/$group.tests.status" + test "${statuses[1]}" -eq 0 + test "${statuses[0]}" -eq 0 + python3 -B - "$DIAGNOSTIC_OUTPUT" "$group" "$filter" <<'PY' + import json, re, sys + from pathlib import Path + output, group, prefix = Path(sys.argv[1]), sys.argv[2], sys.argv[3] + inventory = (output / f'{group}.inventory.log').read_text() + result = (output / f'{group}.tests.log').read_text() + names = re.findall(r'^([A-Za-z0-9_:]+): test$', inventory, re.M) + assert names and len(names) == len(set(names)) and all(name.startswith(prefix) for name in names) + assert ': benchmark' not in inventory + passed = re.findall(r'^test ([A-Za-z0-9_:]+) \.\.\. ok$', result, re.M) + assert sorted(passed) == sorted(names) + summaries = re.findall(r'^test result: ok\. (\d+) passed; (\d+) failed; (\d+) ignored;', result, re.M) + assert summaries == [(str(len(names)), '0', '0')] + required = { + 'diagnostic': ['session_codes_are_exact_and_do_not_change_legacy_error_projection', + 'received_native_codes_require_exact_correlation_and_never_invent_exit_facts', + 'native_worker_unknown_codes_and_extra_private_fields_never_escape_projection'], + 'native_session': ['closed_failure_preserves_only_projected_diagnostic_without_opening_its_source', + 'received_worker_error_cannot_construct_exit_adapter_or_cleanup_authority', + 'closed_projected_failure_is_accepted_by_both_collector_versions'], + 'private_serve': ['same_connection_two_turns_retain_slot_until_explicit_session_close_and_eof', + 'opaque_engine_failure_without_cleanup_facts_quarantines_before_slot_release'], + }[group] + assert all(any(name.endswith('::' + case) for name in names) for case in required) + (output / f'{group}.json').write_text(json.dumps(dict(version=1, filter=prefix, + tests=sorted(names), passed=len(names), failed=0, ignored=0, + model_execution=False), sort_keys=True) + '\n') + PY + done + + - name: Run strict CLI and test Clippy without executing other tests + id: clippy + run: | + set -euo pipefail + set +e + timeout --kill-after=10s 600s cargo +1.85.0 clippy --locked --offline \ + -p volparossa --bin volparossa --tests -- -D warnings \ + 2>&1 | tee "$DIAGNOSTIC_OUTPUT/clippy.log" + statuses=("${PIPESTATUS[@]}") + set -e + printf '%s\n' "${statuses[0]}" > "$DIAGNOSTIC_OUTPUT/clippy.status" + test "${statuses[1]}" -eq 0 + test "${statuses[0]}" -eq 0 + + - name: Check formatting even after a preceding check fails + id: format + if: always() && steps.scope.outcome == 'success' && steps.toolchain.outcome == 'success' + run: | + set -euo pipefail + set +e + timeout --kill-after=5s 90s cargo +1.85.0 fmt --all -- --check \ + 2>&1 | tee "$DIAGNOSTIC_OUTPUT/format.log" + statuses=("${PIPESTATUS[@]}") + set -e + printf '%s\n' "${statuses[0]}" > "$DIAGNOSTIC_OUTPUT/format.status" + test "${statuses[1]}" -eq 0 + test "${statuses[0]}" -eq 0 + + - name: Retain exact outcomes without promoting missing phases + if: always() && steps.scope.outcome == 'success' + env: + TOOLCHAIN_OUTCOME: ${{ steps.toolchain.outcome }} + PYTHON_OUTCOME: ${{ steps.python.outcome }} + FORMAT_OUTCOME: ${{ steps.format.outcome }} + FETCH_OUTCOME: ${{ steps.fetch.outcome }} + RUST_OUTCOME: ${{ steps.rust.outcome }} + CLIPPY_OUTCOME: ${{ steps.clippy.outcome }} + run: | + set -euo pipefail + python3 -B - "$DIAGNOSTIC_OUTPUT/outcomes.json" <<'PY' + import json, os, subprocess, sys + from pathlib import Path + names = ['TOOLCHAIN', 'PYTHON', 'FORMAT', 'FETCH', 'RUST', 'CLIPPY'] + phases = {name.lower(): os.environ[name + '_OUTCOME'] for name in names} + unchanged = not subprocess.check_output(['git', 'status', '--porcelain', '--untracked-files=all']) + Path(sys.argv[1]).write_text(json.dumps(dict(version=1, phases=phases, + checkout_unchanged=unchanged, model_execution=False, + original_actual_failure_cause_proven=False), sort_keys=True) + '\n') + assert unchanged and all(value == 'success' for value in phases.values()) + PY + + - name: Preserve only source, inventory and original check evidence including failures + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: native-session-diagnostics-${{ github.run_id }}-${{ github.run_attempt }} + retention-days: 14 + if-no-files-found: error + path: | + ${{ runner.temp }}/native-session-diagnostics/source.json + ${{ runner.temp }}/native-session-diagnostics/outcomes.json + ${{ runner.temp }}/native-session-diagnostics/python.log + ${{ runner.temp }}/native-session-diagnostics/python.status + ${{ runner.temp }}/native-session-diagnostics/format.log + ${{ runner.temp }}/native-session-diagnostics/format.status + ${{ runner.temp }}/native-session-diagnostics/diagnostic.inventory.log + ${{ runner.temp }}/native-session-diagnostics/diagnostic.inventory.status + ${{ runner.temp }}/native-session-diagnostics/diagnostic.tests.log + ${{ runner.temp }}/native-session-diagnostics/diagnostic.tests.status + ${{ runner.temp }}/native-session-diagnostics/diagnostic.json + ${{ runner.temp }}/native-session-diagnostics/native_session.inventory.log + ${{ runner.temp }}/native-session-diagnostics/native_session.inventory.status + ${{ runner.temp }}/native-session-diagnostics/native_session.tests.log + ${{ runner.temp }}/native-session-diagnostics/native_session.tests.status + ${{ runner.temp }}/native-session-diagnostics/native_session.json + ${{ runner.temp }}/native-session-diagnostics/private_serve.inventory.log + ${{ runner.temp }}/native-session-diagnostics/private_serve.inventory.status + ${{ runner.temp }}/native-session-diagnostics/private_serve.tests.log + ${{ runner.temp }}/native-session-diagnostics/private_serve.tests.status + ${{ runner.temp }}/native-session-diagnostics/private_serve.json + ${{ runner.temp }}/native-session-diagnostics/clippy.log + ${{ runner.temp }}/native-session-diagnostics/clippy.status diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 06dceef9b..e6b0e0dd4 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -120,6 +120,21 @@ jobs: cargo fmt --all -- --check ./scripts/check-shell.sh + - name: Check native CPU contracts without loading models + working-directory: workers/volparossa-ml + run: python3 -B -m unittest test_llama_cpu test_conversion_diagnostic test_native_output_constraint test_native_source_lifecycle test_native_session + + - name: Check native CPU cache hints and unchanged admission without loading models + run: | + cargo test --locked -p volparossa compute::native_cpu + cargo test --locked -p volparossa compute::supervise::native_session + cargo test --locked -p volparossa compute::private_serve::tests::native_session + cargo test --locked -p volparossa compute::sandbox::tests::native_session + cargo test --locked -p volparossa compute::resources::tests + cargo test --locked -p volparossa compute::spare_capacity::memory::tests + python3 -B tests/integration/test-agent-private-conversation.py + python3 -B tests/integration/test-native-source-weight-cache-contract.py + - name: Check bounded bidirectional streaming before audit tool builds # In-memory duplex and real TLS only; no host sockets or namespace setup. run: cargo test --locked -p volparossa-tcp-proxy --test streaming diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index f5e870972..696981a4c 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -4,6 +4,87 @@ Original VOLPAROSSA source in this repository is licensed under GPL-3.0-only. De vendored components retain their own licenses. This file is a provenance record, not a substitute for the license text shipped by each upstream project. +## Explicit llama.cpp CPU inference candidate + +The optional `qwen_tagged_tool_json_v1` profile also uses the pinned internal +`src/llama-grammar.h` API for grammar initialization, cloning, string acceptance, +candidate filtering and freeing. VOLPAROSSA's `output-constraint.h` and `.cpp` +wrap this unchanged upstream implementation; they are original GPL-3.0-only +code, not an upstream patch. Build manifest v3 binds these wrapper files along +with the existing adapter and loader patch. Synthetic-piece tests link the exact +grammar engine without loading a model. API compatibility and sanitizer behavior +require a fresh source build; previous backend receipts do not validate this +extension. + +The opt-in CPU backend uses [ggml-org/llama.cpp](https://github.com/ggml-org/llama.cpp) +at exact commit `7fe450e19305b828c199d602c23a8337aaa1f03b` (resolved release tag +`v0.5.0`; MIT). The tag is unsigned. No prebuilt upstream executable, server, +GPU backend or implicit artifact download is used. The original checkout remains +unchanged; the build applies the explicitly recorded loader and CPU patches below +to fresh staged copies. The GPL-3.0-only adapter exposes a fixed owned C ABI and +source-build flags. +The builder preserves tracked upstream licenses/notices, including ggml and +the Mozilla tinyBLAS notice, unchanged in the generated artifact. +Static GCC runtime archives retain measured hashes and the original Debian GCC +copyright/runtime-exception notice; compiler versions are recorded, not silently +treated as upstream llama.cpp source or reproducible-build proof. + +`workers/volparossa-ml/native-cpu/bounded-tensor-validation.patch` changes both +asynchronous tensor-validation launches to deferred, single-thread execution and +polls the existing cancellation callback between validation results. It preserves +every tensor check, error aggregation and final completion callback. The MIT +upstream code and notices retain their original license; the surrounding owned +adapter and test harness remain GPL-3.0-only. Build manifest v2 records the clean +tree `fd570ef54b10ec5fecb739e7e04c8ce44a6f415a` and these SHA-256 digests: + +- Original `src/llama-model-loader.cpp`: `efdb5f273bd1ab77301f2cadea1a5a804bafb28040aca033e45605a97a450242`. +- Patch: `388fff7d5d9ca812abfeb27fddd1629144b7de6284e4bf685fbc8c147891e8c1`. +- Effective staged loader: `fcd26ea4ae21ea04954b275287c41a35e383015b736bbbe2e68f2ab5f6d095f9`. + +The builder verifies that the compilation database selects exactly the staged +loader. It rejects changed source/patch bytes and retains the clean upstream +checkout and original notices. This is a local patch, not an upstream release. + +`workers/volparossa-ml/native-cpu/q1-aligned-load.patch` replaces one AVX2 +pointer-cast load in `ggml_vec_dot_q1_0_q8_0` with a 16-byte `memcpy` into an +aligned four-element `uint32_t` array. Arithmetic, quantization formats and the +upstream all-type test remain unchanged. The original MIT license is preserved. +Build-v4 provenance records this separate `cpu_source_overlay` and checks both +the selected translation unit and its compiler command against the staged file: + +- Original `ggml/src/ggml-cpu/arch/x86/quants.c`: `3c489fdc77e3ab484a5f188bff9c60c0e9483c951ed65c1a562a04c249d5628e`. +- Patch: `563a178667eeac17e7072c343d1d21a3f641d78c284acbeb2633db26e1c36369`. +- Effective staged CPU source: `b9a63f032ad048a688357194fa535d852959ffee0942c93d4b8fb9544febf6d2`. + +Historical builds omit this provenance pair and do not establish the correction. +The source-only normal and sanitizer builds must verify the new source before +its acceptance; no existing failed sanitizer result is replaced or suppressed. + +The separate offline conversion candidate starts only from the existing pinned +Apache-2.0 `Qwen/Qwen3-4B-Instruct-2507` revision +`cdbee75f17c01a7cc42f958dc650907174af0554`. It compares every original BF16 tensor +value, allowing only exact F32 promotion for norm/one-dimensional tensors; it +does not quantize weights. Original shard identity and the measured GGUF digest +are distinct. The original Qwen license accompanies converted artifacts. +See [the native CPU contract](workers/volparossa-ml/native-cpu/README.md) for +source/build/conversion provenance, instruction requirements and remaining proof. +Source and inert tests are not claims of successful guest inference or coding. + +The explicit native conversion, not the baseline 38-wheel runtime, adds the +official [SentencePiece 0.2.1 wheel](https://pypi.org/project/sentencepiece/0.2.1/) +for CPython 3.13 / manylinux 2.27+ x86-64: 1,387,882 bytes, SHA-256 +`c7f0fd2f2693309e6628aeeb2e2faf6edd221134dfccac3308ca0de01f8dab47`. +Exact origin, metadata hash and optional lock are retained in +`workers/volparossa-ml/native-converter-pins.json`; it is a pinned published +binary, not a claim of a locally reproduced source build. Its Apache-2.0 release +source is [google/sentencepiece at 31646a467d2051eb904e0b45de3a73e91fe1c1e3](https://github.com/google/sentencepiece/tree/31646a467d2051eb904e0b45de3a73e91fe1c1e3) +(`v0.2.1`). The wheel contains no license files. Original release LICENSE and +the original bundled absl, darts_clone, esaxx and protobuf-lite licenses from +that revision are therefore retained unchanged under +`workers/volparossa-ml/native-cpu/sentencepiece*-LICENSE`, with exact source +URLs, sizes and hashes in the optional pins. Converted artifacts include all +five notices; no notice is replaced by this summary. + ## Public SNP verifier fixture trial The disposable public-fixture workflow selects diff --git a/crates/volparossa/src/compute.rs b/crates/volparossa/src/compute.rs index e6d460bcf..c0d16205e 100644 --- a/crates/volparossa/src/compute.rs +++ b/crates/volparossa/src/compute.rs @@ -4,6 +4,7 @@ mod broker; mod device_capacity; mod document_plan; mod inference_output; +mod native_cpu; mod owner_control; mod peer; mod policy_assessment; @@ -117,6 +118,8 @@ pub(crate) struct Options { /// Explicit pinned inference/planning profile; the default also supports training/adapters. #[arg(long, default_value_t = ModelProfile::default())] model_profile: ModelProfile, + #[command(flatten)] + native_backend: native_cpu::Options, /// Explicit verified adapter directory; cache storage alone never activates an adapter. #[arg(long)] adapter_root: Option, @@ -145,6 +148,8 @@ pub(crate) struct Options { #[derive(Serialize)] struct WorkerRequest { + #[serde(flatten)] + native_backend: native_cpu::Request, version: u8, id: String, mode: Mode, @@ -234,7 +239,10 @@ async fn execute(options: &Options, activity: watch::Receiver) -> Result) -> Result) -> Result, + /// Explicit owner-authorized SHA-256 of backend.json, including executable identity. + #[arg(long, requires = "native_backend_root")] + pub native_backend_sha256: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Artifact { + path: String, + sha256: String, + bytes: u64, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Verification { + tensor_count: u32, + tensor_values_equal: bool, + tokenizer_equal: bool, + chat_template_sha256: String, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct Manifest { + version: u8, + kind: String, + abi_version: u8, + source_commit: String, + model_profile: ModelProfile, + source_weights_sha256: String, + source_weights_bytes: u64, + library: Artifact, + gguf: Artifact, + verification: Verification, + build_manifest_sha256: String, + #[serde(default, deserialize_with = "present_output_constraints")] + output_constraints: Option>, + #[serde(default, deserialize_with = "present_native_sessions")] + native_sessions: Option>, +} + +fn present_output_constraints<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> std::result::Result>, D::Error> { + Vec::::deserialize(deserializer).map(Some) +} + +fn present_native_sessions<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> std::result::Result>, D::Error> { + Vec::::deserialize(deserializer).map(Some) +} + +impl Manifest { + fn validate(&self) -> Result<()> { + ensure!( + ((self.version == 1 && self.output_constraints.is_none()) + || ((self.version == 2 || self.version == 3) + && self.output_constraints.as_deref() + == Some(&[OutputConstraint::QwenTaggedToolJsonV1]))) + && (if self.version == 3 { + self.native_sessions.as_deref() == Some(&["connection_bound_v1".to_owned()]) + } else { + self.native_sessions.is_none() + }) + && self.kind == KIND + && self.abi_version == 1 + && self.source_commit == SOURCE + && self.model_profile == ModelProfile::Qwen4bInstruct2507 + && self.source_weights_sha256 == WEIGHTS + && self.source_weights_bytes == 8_044_982_000, + "compute_native_backend_identity" + ); + ensure!( + self.library.path == "libvolparossa_llama_cpu.so" + && (1..=128 * 1024 * 1024).contains(&self.library.bytes) + && self.gguf.path == "model.gguf" + && (1..=9 * 1024 * 1024 * 1024).contains(&self.gguf.bytes) + && [ + &self.library.sha256, + &self.gguf.sha256, + &self.build_manifest_sha256, + &self.verification.chat_template_sha256 + ] + .iter() + .all(|hash| super::is_hex(hash, 64)), + "compute_native_backend_artifacts" + ); + ensure!( + self.verification.tensor_count == 398 + && self.verification.tensor_values_equal + && self.verification.tokenizer_equal, + "compute_native_backend_conversion" + ); + Ok(()) + } + + fn report(&self, hash: &str) -> Value { + json!({"kind":KIND,"abi_version":1,"source_commit":SOURCE, + "manifest_sha256":hash,"library_sha256":self.library.sha256, + "gguf_sha256":self.gguf.sha256,"gguf_bytes":self.gguf.bytes, + "source_weights_sha256":WEIGHTS, + "verification_scope":{"version":1, + "source_weights":"initial_complete_bytes_only", + "execution_weights":"gguf_complete_bytes_before_and_after"}}) + } +} + +impl Options { + pub(super) fn enabled(&self) -> bool { + self.native_backend_root.is_some() || self.native_backend_sha256.is_some() + } + + pub(super) fn validate(&self, profile: ModelProfile) -> Result<()> { + self.request(profile).map(|_| ()) + } + + pub(super) fn validate_input( + &self, + mode: Mode, + raw: &[u8], + profile: ModelProfile, + ) -> Result<()> { + if self.enabled() { + ensure!( + mode == Mode::PrivateConversation && profile == ModelProfile::Qwen4bInstruct2507, + "compute_native_backend_scope" + ); + let input = super::private_conversation::Input::decode_profile(raw, profile)?; + ensure!( + input.requires_generation_policy_handshake(), + "compute_native_backend_policy" + ); + } + if mode == Mode::PrivateConversation { + let input = super::private_conversation::Input::decode_profile(raw, profile)?; + if input.output_constraint().is_some() { + self.request(profile)?.select_constraint(&input)?; + } + } + Ok(()) + } + + pub(super) fn supports_output_constraint(&self, profile: ModelProfile) -> bool { + self.request(profile) + .is_ok_and(|request| request.supported_output_constraint.is_some()) + } + + pub(super) fn supports_native_session(&self, profile: ModelProfile) -> bool { + self.request(profile) + .is_ok_and(|request| request.supported_native_session) + } + + pub(super) fn request_for_dataset( + &self, + profile: ModelProfile, + mode: Mode, + dataset: &Path, + ) -> Result { + let mut request = self.request(profile)?; + if mode == Mode::PrivateConversation { + let raw = super::read_file( + dataset, + super::private_conversation::request_frame(profile) as u64, + )?; + let input = super::private_conversation::Input::decode_profile(&raw, profile)?; + request.select_constraint(&input)?; + } + Ok(request) + } + + pub(super) fn request(&self, profile: ModelProfile) -> Result { + let (root, hash) = match (&self.native_backend_root, &self.native_backend_sha256) { + (None, None) => return Ok(Request::default()), + (Some(root), Some(hash)) => (root, hash), + _ => anyhow::bail!("compute_native_backend_authorization"), + }; + ensure!( + profile == ModelProfile::Qwen4bInstruct2507 && super::is_hex(hash, 64), + "compute_native_backend_authorization" + ); + super::private_directory(root)?; + let raw = super::read_file(&root.join("backend.json"), 16 * 1024)?; + ensure!( + hex::encode(Sha256::digest(&raw)) == *hash, + "compute_native_backend_manifest_hash" + ); + let manifest: Manifest = serde_json::from_slice(&raw) + .map_err(|_| anyhow::anyhow!("compute_native_backend_manifest_schema"))?; + manifest.validate()?; + for artifact in [&manifest.library, &manifest.gguf] { + let info = fs::symlink_metadata(root.join(&artifact.path))?; + ensure!( + info.is_file() + && info.uid() == nix::unistd::geteuid().as_raw() + && info.nlink() == 1 + && info.mode().trailing_zeros() >= 6 + && info.len() == artifact.bytes, + "compute_native_backend_file" + ); + } + // The fixed isolated worker verifies both complete artifact hashes and + // build provenance before dlopen. No native library is loaded by the core. + Ok(Request { + inference_backend: Some(KIND), + native_backend_root: Some("/native-backend"), + native_backend_sha256: Some(hash.clone()), + expected_report: Some(manifest.report(hash)), + supported_output_constraint: manifest + .output_constraints + .as_ref() + .and_then(|values| values.first().copied()), + output_constraint: None, + output_constraint_tools: None, + supported_native_session: manifest.version == 3, + }) + } +} + +#[derive(Default, Serialize)] +pub(super) struct Request { + #[serde(skip_serializing_if = "Option::is_none")] + inference_backend: Option<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] + native_backend_root: Option<&'static str>, + #[serde(skip_serializing_if = "Option::is_none")] + native_backend_sha256: Option, + #[serde(skip_serializing_if = "Option::is_none")] + output_constraint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + output_constraint_tools: Option>, + #[serde(skip)] + expected_report: Option, + #[serde(skip)] + supported_output_constraint: Option, + #[serde(skip)] + supported_native_session: bool, +} + +impl Request { + #[cfg(test)] + pub(super) fn cache_hint(&self, options: &Options, profile: ModelProfile) -> Option { + self.cache_hint_observed(options, profile).hint + } + + pub(super) fn cache_hint_observed(&self, options: &Options, profile: ModelProfile) -> Binding { + if profile != ModelProfile::Qwen4bInstruct2507 + || self.inference_backend != Some(KIND) + || self.native_backend_sha256 != options.native_backend_sha256 + { + return Binding::unavailable("not_applicable"); + } + let Some(root) = options.native_backend_root.as_ref() else { + return Binding::unavailable("not_applicable"); + }; + let Some(bytes) = self + .expected_report + .as_ref() + .and_then(|report| report.get("gguf_bytes")) + .and_then(Value::as_u64) + else { + return Binding::unavailable("not_applicable"); + }; + match CacheHint::bind_observed(root, bytes) { + Ok(hint) => Binding { + hint: Some(hint), + reason: "bound", + }, + Err(reason) => Binding::unavailable(reason), + } + } + + fn select_constraint(&mut self, input: &super::private_conversation::Input) -> Result<()> { + if let Some(constraint) = input.output_constraint() { + ensure!( + self.enabled() && self.supported_output_constraint == Some(constraint), + "compute_native_backend_output_constraint" + ); + } + self.output_constraint = input.output_constraint(); + self.output_constraint_tools = input.output_constraint_tools(); + Ok(()) + } + + pub(super) fn enabled(&self) -> bool { + self.inference_backend.is_some() + } + + pub(super) fn check_report(&self, value: &Value) -> Result<()> { + ensure!( + value.get("inference_backend") == self.expected_report.as_ref(), + "compute_native_backend_result_binding" + ); + let expected_constraint = self.output_constraint.map(|constraint| json!(constraint)); + ensure!( + value.get("output_constraint") == expected_constraint.as_ref() + && value.get("output_constraint_tools").is_none(), + "compute_native_backend_output_constraint_binding" + ); + if self.enabled() { + ensure!( + value["model_parameter_dtype"] == "bf16_with_exact_f32_norms" + && value["model_attention_backend"] == "llama_cpp_cpu", + "compute_native_backend_result_precision" + ); + } + Ok(()) + } +} + +/// Preserve the existing post-supervisor advisories, once after the whole +/// connection-owned process has joined, never after a still-resident turn. +pub(super) struct SessionHints { + gguf: Binding, + source: source_weight_cache::Binding, + profile: ModelProfile, + diagnostic: bool, +} + +impl SessionHints { + pub(super) fn bind(request: &Request, options: &super::Options) -> Self { + Self { + gguf: request.cache_hint_observed(&options.native_backend, options.model_profile), + source: source_weight_cache::Binding::bind( + request, + &options.native_backend, + options.model_profile, + &options.model_root, + ), + profile: options.model_profile, + diagnostic: true, + } + } + + pub(super) fn finish(self, result: &Result) { + self.gguf + .after_supervisor(result, self.profile, self.diagnostic); + self.source.after_supervisor(result, self.diagnostic); + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/volparossa/src/compute/native_cpu/cache_hint.rs b/crates/volparossa/src/compute/native_cpu/cache_hint.rs new file mode 100644 index 000000000..6945f5e2d --- /dev/null +++ b/crates/volparossa/src/compute/native_cpu/cache_hint.rs @@ -0,0 +1,223 @@ +//! Best-effort cache advice for one owner-authorized, unchanged native GGUF. +//! This is not memory admission, guaranteed reclaim, or evidence of a cache cause. + +use std::{ + fs::{self, File, Metadata, OpenOptions}, + os::unix::fs::{MetadataExt as _, OpenOptionsExt as _}, + path::{Path, PathBuf}, +}; + +use anyhow::Result; +use nix::fcntl::{PosixFadviseAdvice, posix_fadvise}; +use serde_json::{Value, json}; + +use super::super::{ModelProfile, private_directory, resources, spare_capacity::memory}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct Identity { + device: u64, + inode: u64, + owner: u32, + links: u64, + mode: u32, + bytes: u64, + modified: (i64, i64), + changed: (i64, i64), +} + +impl Identity { + fn read(info: &Metadata) -> Self { + Self { + device: info.dev(), + inode: info.ino(), + owner: info.uid(), + links: info.nlink(), + mode: info.mode(), + bytes: info.len(), + modified: (info.mtime(), info.mtime_nsec()), + changed: (info.ctime(), info.ctime_nsec()), + } + } + + fn valid(self, expected_bytes: u64) -> bool { + self.mode & libc::S_IFMT == libc::S_IFREG + && self.owner == nix::unistd::geteuid().as_raw() + && self.links == 1 + && self.mode.trailing_zeros() >= 6 + && self.bytes == expected_bytes + && (1..=9 * 1024 * 1024 * 1024).contains(&self.bytes) + } +} + +pub(in crate::compute) struct CacheHint { + file: File, + root: PathBuf, + root_identity: (u64, u64), + identity: Identity, +} + +pub(in crate::compute) struct Binding { + pub(super) hint: Option, + pub(super) reason: &'static str, +} + +impl Binding { + pub(super) fn unavailable(reason: &'static str) -> Self { + Self { hint: None, reason } + } + + pub(in crate::compute) fn after_supervisor( + self, + result: &Result, + profile: ModelProfile, + diagnostic: bool, + ) { + let observe = diagnostic && memory::enabled(); + let record = self.observe_with(result, profile, observe, memory::sample, |file, bytes| { + posix_fadvise(file, 0, bytes, PosixFadviseAdvice::POSIX_FADV_DONTNEED) + }); + if observe { + memory::emit(&record); + } + } + + fn observe_with( + self, + result: &Result, + profile: ModelProfile, + observe: bool, + mut sample: impl FnMut(bool) -> memory::Sample, + advise: impl FnOnce(&File, libc::off_t) -> nix::Result<()>, + ) -> Value { + let mut record = json!({"version":1,"phase":"cache_hint","binding":self.reason, + "outcome":"supervisor_error","supervisor_ok":result.is_ok(), + "before":null,"after":null,"syscall_error":null}); + if result.is_err() { + return record; + } + let Some(hint) = self.hint else { + record["outcome"] = json!("not_bound"); + return record; + }; + let before = sample(observe); + if observe { + record["before"] = before.snapshot(); + } + let outcome = if before.headroom.is_none() { + "headroom_unknown" + } else if !needs_hint(profile, before.headroom) { + "headroom_sufficient" + } else if !hint.unchanged() { + "identity_changed" + } else if let Ok(bytes) = libc::off_t::try_from(hint.identity.bytes) { + // Preserve one synchronous, unchanged-file advice call under the lease. + // The following observation is not proof that this hint freed memory. + let syscall = advise(&hint.file, bytes); + if observe { + record["after"] = sample(true).snapshot(); + } + if let Err(error) = syscall { + record["syscall_error"] = json!(match error { + nix::errno::Errno::EBADF => "bad_fd", + nix::errno::Errno::EINVAL => "invalid", + nix::errno::Errno::ENOSYS | nix::errno::Errno::EOPNOTSUPP => "unsupported", + nix::errno::Errno::EINTR => "interrupted", + _ => "other", + }); + "error" + } else { + "advised" + } + } else { + "range_invalid" + }; + record["outcome"] = json!(outcome); + record + } +} + +impl CacheHint { + // Called only from a request already bound to the owner's manifest digest. + #[cfg(test)] + pub(super) fn bind(root: &Path, expected_bytes: u64) -> Option { + Self::bind_observed(root, expected_bytes).ok() + } + + pub(super) fn bind_observed( + root: &Path, + expected_bytes: u64, + ) -> std::result::Result { + private_directory(root).map_err(|_| "root_invalid")?; + let directory = fs::symlink_metadata(root).map_err(|_| "root_invalid")?; + let file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK) + .open(root.join("model.gguf")) + .map_err(|_| "open_failed")?; + let identity = Identity::read(&file.metadata().map_err(|_| "file_invalid")?); + if !identity.valid(expected_bytes) { + return Err("file_invalid"); + } + let hint = Self { + file, + root: root.to_owned(), + root_identity: (directory.dev(), directory.ino()), + identity, + }; + if hint.unchanged() { + Ok(hint) + } else { + Err("path_changed") + } + } + + fn unchanged(&self) -> bool { + let check = || -> Option { + private_directory(&self.root).ok()?; + let directory = fs::symlink_metadata(&self.root).ok()?; + let descriptor = Identity::read(&self.file.metadata().ok()?); + let path = Identity::read(&fs::symlink_metadata(self.root.join("model.gguf")).ok()?); + Some( + (directory.dev(), directory.ino()) == self.root_identity + && descriptor.valid(self.identity.bytes) + && descriptor == self.identity + && path == self.identity, + ) + }; + check() == Some(true) + } + + #[cfg(test)] + fn after_supervisor_with( + self, + result: &Result, + profile: ModelProfile, + headroom: impl FnOnce() -> Option, + advise: impl FnOnce(&File, libc::off_t) -> nix::Result<()>, + ) { + let mut headroom = Some(headroom); + Binding { + hint: Some(self), + reason: "bound", + } + .observe_with( + result, + profile, + false, + |_| memory::Sample::for_test(headroom.take().expect("one sample")()), + advise, + ); + } +} + +fn needs_hint(profile: ModelProfile, headroom: Option) -> bool { + profile == ModelProfile::Qwen4bInstruct2507 + && headroom.is_some_and(|available| { + resources::limits(profile) + .admission_headroom + .is_some_and(|required| available < required) + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/volparossa/src/compute/native_cpu/cache_hint/tests.rs b/crates/volparossa/src/compute/native_cpu/cache_hint/tests.rs new file mode 100644 index 000000000..6f7924704 --- /dev/null +++ b/crates/volparossa/src/compute/native_cpu/cache_hint/tests.rs @@ -0,0 +1,487 @@ +//! Tiny inert files and injected advice only; no model, native code, or cache syscall. + +use std::{ + cell::Cell, + os::unix::fs::{PermissionsExt as _, symlink}, + time::{Duration, SystemTime}, +}; + +use nix::fcntl::{FcntlArg, fcntl}; + +use super::super::{Options, tests::fixture}; +use super::*; + +const PROFILE: ModelProfile = ModelProfile::Qwen4bInstruct2507; + +fn bound(options: &Options) -> CacheHint { + options + .request(PROFILE) + .unwrap() + .cache_hint(options, PROFILE) + .unwrap() +} + +#[test] +fn hint_requires_known_shortage_without_changing_admission() { + let required = 10 * 1024 * 1024 * 1024 + 512 * 1024 * 1024; + assert_eq!( + resources::limits(PROFILE).admission_headroom, + Some(required) + ); + for (available, hint, admitted) in [ + (None, false, false), + (Some(0), true, false), + (Some(required - 1), true, false), + (Some(required), false, true), + (Some(required + 1), false, true), + ] { + assert_eq!(needs_hint(PROFILE, available), hint); + assert_eq!(resources::admits(PROFILE, available), admitted); + } + for profile in [ + ModelProfile::Default135, + ModelProfile::Smol360, + ModelProfile::Smol1700, + ModelProfile::Qwen600, + ] { + assert!(!needs_hint(profile, Some(0))); + } +} + +#[test] +fn hint_binding_requires_the_same_owner_authorized_native_request() { + let (_root, mut options) = fixture(); + let request = options.request(PROFILE).unwrap(); + assert!(request.cache_hint(&options, PROFILE).is_some()); + assert!( + request + .cache_hint(&options, ModelProfile::Qwen600) + .is_none() + ); + assert!(request.cache_hint(&Options::default(), PROFILE).is_none()); + options.native_backend_sha256 = Some("0".repeat(64)); + assert!(request.cache_hint(&options, PROFILE).is_none()); + assert!(options.request(PROFILE).is_err()); + assert!( + Options::default() + .request(PROFILE) + .unwrap() + .cache_hint(&options, PROFILE) + .is_none() + ); +} + +#[test] +fn bound_descriptor_is_read_only_cloexec_and_matches_exact_file() { + let (_root, options) = fixture(); + let hint = bound(&options); + assert!(hint.unchanged()); + assert_eq!(hint.identity.bytes, 1); + assert_ne!( + fcntl(&hint.file, FcntlArg::F_GETFD).unwrap() & libc::FD_CLOEXEC, + 0 + ); + assert_eq!( + fcntl(&hint.file, FcntlArg::F_GETFL).unwrap() & libc::O_ACCMODE, + libc::O_RDONLY + ); +} + +#[test] +fn file_identity_requires_regular_private_single_link_owned_exact_size() { + let (_root, options) = fixture(); + let identity = bound(&options).identity; + assert!(identity.valid(1)); + assert!(!identity.valid(2)); + for changed in [ + Identity { + mode: libc::S_IFDIR | 0o600, + ..identity + }, + Identity { + mode: identity.mode | 0o004, + ..identity + }, + Identity { + owner: identity.owner.wrapping_add(1), + ..identity + }, + Identity { + links: 2, + ..identity + }, + Identity { + bytes: 0, + ..identity + }, + Identity { + bytes: 10 * 1024 * 1024 * 1024, + ..identity + }, + ] { + assert!(!changed.valid(changed.bytes)); + } + for changed in [ + Identity { + device: identity.device.wrapping_add(1), + ..identity + }, + Identity { + inode: identity.inode.wrapping_add(1), + ..identity + }, + Identity { + modified: (identity.modified.0 + 1, 0), + ..identity + }, + Identity { + changed: (identity.changed.0 + 1, 0), + ..identity + }, + ] { + assert_ne!(changed, identity); + } +} + +#[test] +fn optional_binding_rejects_symlink_hardlink_nonregular_and_unprivate_parent() { + let (root, _options) = fixture(); + let model = root.path().join("model.gguf"); + fs::rename(&model, root.path().join("original")).unwrap(); + symlink("original", &model).unwrap(); + assert!(CacheHint::bind(root.path(), 1).is_none()); + fs::remove_file(&model).unwrap(); + fs::hard_link(root.path().join("original"), &model).unwrap(); + assert!(CacheHint::bind(root.path(), 1).is_none()); + fs::remove_file(&model).unwrap(); + fs::create_dir(&model).unwrap(); + assert!(CacheHint::bind(root.path(), 1).is_none()); + fs::remove_dir(&model).unwrap(); + fs::rename(root.path().join("original"), &model).unwrap(); + assert!(CacheHint::bind(root.path(), 2).is_none()); + fs::set_permissions(root.path(), fs::Permissions::from_mode(0o755)).unwrap(); + assert!(CacheHint::bind(root.path(), 1).is_none()); +} + +#[test] +fn replaced_same_size_file_is_never_advised() { + let (root, options) = fixture(); + let hint = bound(&options); + let model = root.path().join("model.gguf"); + fs::rename(&model, root.path().join("original")).unwrap(); + fs::write(&model, b"x").unwrap(); + fs::set_permissions(&model, fs::Permissions::from_mode(0o600)).unwrap(); + assert!(!hint.unchanged()); + hint.after_supervisor_with(&Ok(()), PROFILE, || Some(0), |_, _| panic!("replacement")); +} + +#[test] +fn changed_metadata_and_parent_identity_are_never_advised() { + for change in 0..4 { + let (root, options) = fixture(); + let hint = bound(&options); + let model = root.path().join("model.gguf"); + match change { + 0 => fs::set_permissions(&model, fs::Permissions::from_mode(0o400)).unwrap(), + 1 => fs::write(&model, b"xx").unwrap(), + 2 => hint + .file + .set_modified(SystemTime::UNIX_EPOCH + Duration::from_secs(1)) + .unwrap(), + _ => fs::set_permissions(root.path(), fs::Permissions::from_mode(0o755)).unwrap(), + } + assert!(!hint.unchanged()); + hint.after_supervisor_with(&Ok(()), PROFILE, || Some(0), |_, _| panic!("changed")); + } + let (_root, options) = fixture(); + let mut hint = bound(&options); + hint.root_identity.1 = hint.root_identity.1.wrapping_add(1); + assert!(!hint.unchanged()); +} + +#[test] +fn successful_supervisor_allows_one_exact_range_hint_without_reading_model() { + let (_root, options) = fixture(); + let hint = bound(&options); + let expected = hint.identity; + let calls = Cell::new(0); + let result = Ok("original result"); + hint.after_supervisor_with( + &result, + PROFILE, + || Some(0), + |file, bytes| { + calls.set(calls.get() + 1); + assert_eq!(bytes, 1); + assert_eq!(Identity::read(&file.metadata().unwrap()), expected); + Ok(()) + }, + ); + assert_eq!(calls.get(), 1); + assert!(matches!(result, Ok("original result"))); +} + +#[test] +fn hint_failure_does_not_change_success_or_claim_reclaimed_memory() { + let (_root, options) = fixture(); + let result = Ok("original result"); + bound(&options).after_supervisor_with( + &result, + PROFILE, + || Some(0), + |_, _| Err(nix::errno::Errno::EIO), + ); + assert!(matches!(result, Ok("original result"))); +} + +#[test] +fn errors_including_unconfirmed_cleanup_never_sample_or_hint() { + let (_root, options) = fixture(); + for error in [ + anyhow::Error::new(crate::compute::private_task::CleanupUnconfirmed), + anyhow::anyhow!("compute_cancelled"), + anyhow::anyhow!("PRIVATE_ERROR_CANARY /private/path"), + ] { + let original = error.to_string(); + let uncertain_cleanup = error.is::(); + let result: Result<()> = Err(error); + bound(&options).after_supervisor_with( + &result, + PROFILE, + || panic!("error sample"), + |_, _| panic!("error hint"), + ); + let Err(error) = &result else { + panic!("original error replaced"); + }; + assert_eq!(error.to_string(), original); + assert_eq!( + error.is::(), + uncertain_cleanup + ); + } +} + +#[test] +fn missing_or_sufficient_headroom_never_hints() { + let (_root, options) = fixture(); + for available in [ + None, + resources::limits(PROFILE).admission_headroom, + Some(u64::MAX), + ] { + bound(&options).after_supervisor_with( + &Ok(()), + PROFILE, + || available, + |_, _| panic!("no measured shortage"), + ); + } +} + +fn observed(options: &Options) -> Binding { + options + .request(PROFILE) + .unwrap() + .cache_hint_observed(options, PROFILE) +} + +fn collect(records: &[Value]) { + use std::io::Write as _; + let mut log = tempfile::NamedTempFile::new().unwrap(); + for record in records { + let line = format!("private_memory_diagnostic {record}\n"); + assert!(line.len() <= 4096); + assert!(!line.contains("CANARY")); + log.write_all(line.as_bytes()).unwrap(); + } + let script = r" +import json, runpy, sys +from pathlib import Path +module = runpy.run_path(sys.argv[1]) +path = Path(sys.argv[2]) +original = [json.loads(line.split(' ', 1)[1]) for line in path.read_text().splitlines()] +legacy = module['service_diagnostic'](path) +assert legacy == dict(version=1, truncated=False, unrecognized_record=False, events=[]) +current = module['service_diagnostic'](path, memory_enabled=True) +assert current == dict(version=2, truncated=False, unrecognized_record=False, + events=[], memory=original) +"; + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/integration/agent-private-conversation.py"); + let parsed = std::process::Command::new("python3") + .args(["-B", "-c", script]) + .arg(fixture) + .arg(log.path()) + .output() + .unwrap(); + assert!(parsed.status.success()); + assert!(parsed.stdout.is_empty() && parsed.stderr.is_empty()); +} + +#[test] +fn cache_observation_preserves_cleanup_and_cancel_priority_without_private_error_text() { + let (_root, options) = fixture(); + let mut records = Vec::new(); + for error in [ + anyhow::Error::new(crate::compute::private_task::CleanupUnconfirmed), + anyhow::anyhow!("compute_cancelled"), + anyhow::anyhow!("PRIVATE_CACHE_CANARY /private/path"), + ] { + let original = error.to_string(); + let result: Result<()> = Err(error); + let record = observed(&options).observe_with( + &result, + PROFILE, + true, + |_| panic!("sample before confirmed cleanup"), + |_, _| panic!("hint before confirmed cleanup"), + ); + let Err(preserved_error) = result else { + panic!("supervisor failure was replaced by success"); + }; + assert_eq!(preserved_error.to_string(), original); + assert_eq!(record["outcome"], "supervisor_error"); + assert_eq!(record["supervisor_ok"], false); + records.push(record); + } + collect(&records); +} + +#[test] +fn unavailable_hint_and_unknown_or_sufficient_headroom_remain_non_authoritative() { + let (_root, options) = fixture(); + let mut records = Vec::new(); + for reason in [ + "not_applicable", + "root_invalid", + "open_failed", + "file_invalid", + "path_changed", + ] { + let record = Binding::unavailable(reason).observe_with( + &Ok(()), + PROFILE, + true, + |_| panic!("unbound sample"), + |_, _| panic!("unbound advice"), + ); + assert_eq!(record["outcome"], "not_bound"); + records.push(record); + } + for (available, outcome) in [ + (None, "headroom_unknown"), + ( + resources::limits(PROFILE).admission_headroom, + "headroom_sufficient", + ), + (Some(u64::MAX), "headroom_sufficient"), + ] { + let calls = Cell::new(0); + let record = observed(&options).observe_with( + &Ok(()), + PROFILE, + true, + |stats| { + assert!(stats); + calls.set(calls.get() + 1); + memory::Sample::for_test(available) + }, + |_, _| panic!("unproven shortage"), + ); + assert_eq!(calls.get(), 1); + assert_eq!(record["outcome"], outcome); + records.push(record); + } + collect(&records); +} + +#[test] +fn advice_observation_reports_one_syscall_and_closed_errno_without_reclaim_claim() { + let (_root, options) = fixture(); + let mut records = Vec::new(); + for (advice, expected) in [ + (Ok(()), None), + (Err(nix::errno::Errno::EBADF), Some("bad_fd")), + (Err(nix::errno::Errno::EINVAL), Some("invalid")), + (Err(nix::errno::Errno::ENOSYS), Some("unsupported")), + (Err(nix::errno::Errno::EOPNOTSUPP), Some("unsupported")), + (Err(nix::errno::Errno::EINTR), Some("interrupted")), + (Err(nix::errno::Errno::EIO), Some("other")), + ] { + let samples = Cell::new(0); + let calls = Cell::new(0); + let original = Ok("original result"); + let record = observed(&options).observe_with( + &original, + PROFILE, + true, + |stats| { + assert!(stats); + samples.set(samples.get() + 1); + memory::Sample::for_test(if samples.get() == 1 { Some(0) } else { None }) + }, + |_, bytes| { + assert_eq!(bytes, 1); + assert_eq!(samples.get(), 1); + calls.set(calls.get() + 1); + advice + }, + ); + assert_eq!(samples.get(), 2); + assert_eq!(calls.get(), 1); + assert!(matches!(original, Ok("original result"))); + assert_eq!( + record["outcome"], + if expected.is_some() { + "error" + } else { + "advised" + } + ); + assert_eq!(record["syscall_error"], json!(expected)); + assert_eq!(record["after"]["missing_stage"], "meminfo"); + records.push(record); + } + collect(&records); +} + +#[test] +fn changed_hint_identity_is_observed_without_advice_or_second_sample() { + let (root, options) = fixture(); + let binding = observed(&options); + fs::set_permissions(root.path(), fs::Permissions::from_mode(0o755)).unwrap(); + let calls = Cell::new(0); + let record = binding.observe_with( + &Ok(()), + PROFILE, + true, + |_| { + calls.set(calls.get() + 1); + memory::Sample::for_test(Some(0)) + }, + |_, _| panic!("changed identity"), + ); + assert_eq!(record["outcome"], "identity_changed"); + assert_eq!(calls.get(), 1); + collect(&[record]); +} + +#[test] +fn opt_out_does_not_read_stats_or_add_an_after_hint_sample() { + let (_root, options) = fixture(); + let samples = Cell::new(0); + let record = observed(&options).observe_with( + &Ok(()), + PROFILE, + false, + |stats| { + assert!(!stats); + samples.set(samples.get() + 1); + memory::Sample::for_test(Some(0)) + }, + |_, _| Ok(()), + ); + assert_eq!(samples.get(), 1); + assert!(record["before"].is_null() && record["after"].is_null()); +} diff --git a/crates/volparossa/src/compute/native_cpu/source_weight_cache.rs b/crates/volparossa/src/compute/native_cpu/source_weight_cache.rs new file mode 100644 index 000000000..3273708d5 --- /dev/null +++ b/crates/volparossa/src/compute/native_cpu/source_weight_cache.rs @@ -0,0 +1,260 @@ +//! Advice only for the three fixed source shards read by a successful native worker. +//! Source hashes remain mandatory. File-cache charges are not attributed to these +//! files, and advisory success is neither reclaimed memory nor admission credit. + +use std::{ + fs::{self, File, Metadata, OpenOptions}, + os::unix::fs::{MetadataExt as _, OpenOptionsExt as _}, + path::{Path, PathBuf}, +}; + +use anyhow::Result; +use nix::fcntl::{PosixFadviseAdvice, posix_fadvise}; +use serde_json::{Value, json}; +use volparossa_content::model_profile::ModelFileSpec; + +use super::super::{ + ModelProfile, private_directory, private_task, resources, spare_capacity::memory, +}; +use super::{KIND, Options, Request}; + +const PROFILE: ModelProfile = ModelProfile::Qwen4bInstruct2507; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +struct Identity { + device: u64, + inode: u64, + owner: u32, + links: u64, + mode: u32, + bytes: u64, + modified: (i64, i64), + changed: (i64, i64), +} + +impl Identity { + fn read(info: &Metadata) -> Self { + Self { + device: info.dev(), + inode: info.ino(), + owner: info.uid(), + links: info.nlink(), + mode: info.mode(), + bytes: info.len(), + modified: (info.mtime(), info.mtime_nsec()), + changed: (info.ctime(), info.ctime_nsec()), + } + } + + fn valid(self, expected_bytes: u64) -> bool { + self.mode & libc::S_IFMT == libc::S_IFREG + && self.owner == nix::unistd::geteuid().as_raw() + && self.links == 1 + && self.mode.trailing_zeros() >= 6 + && self.bytes == expected_bytes + && (1..=4 * 1024 * 1024 * 1024).contains(&self.bytes) + } +} + +struct Shard { + file: File, + name: &'static str, + identity: Identity, +} + +impl Shard { + fn bind(root: &Path, spec: &ModelFileSpec) -> std::result::Result { + let file = OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK) + .open(root.join(spec.name)) + .map_err(|_| "open_failed")?; + let identity = Identity::read(&file.metadata().map_err(|_| "file_invalid")?); + if !identity.valid(spec.bytes) { + return Err("file_invalid"); + } + Ok(Self { + file, + name: spec.name, + identity, + }) + } + + fn unchanged(&self, root: &Path) -> bool { + let check = || -> Option { + let descriptor = Identity::read(&self.file.metadata().ok()?); + let path = Identity::read(&fs::symlink_metadata(root.join(self.name)).ok()?); + Some(descriptor == self.identity && path == self.identity) + }; + check() == Some(true) + } +} + +struct Hints { + root: PathBuf, + root_identity: (u64, u64), + shards: [Shard; 3], +} + +impl Hints { + // Production callers pass only PROFILE.sharded_weight_files()[1..]. There is + // no user filename, remote index, glob, recursive traversal or content read. + fn bind(root: &Path, specs: &[ModelFileSpec; 3]) -> std::result::Result { + private_directory(root).map_err(|_| "root_invalid")?; + let directory = fs::symlink_metadata(root).map_err(|_| "root_invalid")?; + let hints = Self { + root: root.to_owned(), + root_identity: (directory.dev(), directory.ino()), + shards: [ + Shard::bind(root, &specs[0])?, + Shard::bind(root, &specs[1])?, + Shard::bind(root, &specs[2])?, + ], + }; + if hints.parent_unchanged() && hints.shards.iter().all(|shard| shard.unchanged(root)) { + Ok(hints) + } else { + Err("path_changed") + } + } + + fn parent_unchanged(&self) -> bool { + private_directory(&self.root).is_ok() + && fs::symlink_metadata(&self.root) + .is_ok_and(|info| (info.dev(), info.ino()) == self.root_identity) + } +} + +pub(in crate::compute) struct Binding { + hints: Option, + reason: &'static str, +} + +impl Binding { + pub(in crate::compute) fn bind( + request: &Request, + options: &Options, + profile: ModelProfile, + model_root: &Path, + ) -> Self { + let unavailable = |reason| Self { + hints: None, + reason, + }; + if profile != PROFILE + || request.inference_backend != Some(KIND) + || request.native_backend_sha256 != options.native_backend_sha256 + || options.native_backend_root.is_none() + || request.expected_report.is_none() + { + return unavailable("not_applicable"); + } + let Some([_, first, second, third]) = PROFILE.sharded_weight_files() else { + return unavailable("not_applicable"); + }; + match Hints::bind(model_root, &[*first, *second, *third]) { + Ok(hints) => Self { + hints: Some(hints), + reason: "bound", + }, + Err(reason) => unavailable(reason), + } + } + + pub(in crate::compute) fn after_supervisor(self, result: &Result, diagnostic: bool) { + // Separate opt-in: the existing memory diagnostic target/marker/schema + // remains unchanged and old collectors ignore this new marker entirely. + let observe = diagnostic + && tracing::enabled!(target: "volparossa::compute::private_source_weight_cache_diagnostic", tracing::Level::DEBUG); + let record = self.observe_with(result, observe, memory::sample, |file, bytes| { + posix_fadvise(file, 0, bytes, PosixFadviseAdvice::POSIX_FADV_DONTNEED) + }); + if observe { + tracing::debug!(target: "volparossa::compute::private_source_weight_cache_diagnostic", + "private_source_weight_cache_diagnostic {record}"); + } + } + + fn observe_with( + self, + result: &Result, + observe: bool, + mut sample: impl FnMut(bool) -> memory::Sample, + mut advise: impl FnMut(&File, libc::off_t) -> nix::Result<()>, + ) -> Value { + let mut record = json!({"version":1,"phase":"source_weights","binding":self.reason, + "outcome":"supervisor_error","supervisor_ok":result.is_ok(), + "before":null,"after":null,"shards":[]}); + let Ok(result) = result else { return record }; + let Some(hints) = self.hints else { + record["outcome"] = json!("not_bound"); + return record; + }; + // The real caller has already validated this after joining every worker + // lifetime. Retain an explicit closed provenance gate at the advice edge. + if private_task::validate_model_weights(&result["model"], PROFILE).is_err() { + record["outcome"] = json!("provenance_unconfirmed"); + return record; + } + let before = sample(observe); + if observe { + record["before"] = before.snapshot(); + } + let Some(headroom) = before.headroom else { + record["outcome"] = json!("headroom_unknown"); + return record; + }; + if resources::admits(PROFILE, Some(headroom)) { + record["outcome"] = json!("headroom_sufficient"); + return record; + } + if !hints.parent_unchanged() + || !hints + .shards + .iter() + .all(|shard| shard.unchanged(&hints.root)) + { + record["outcome"] = json!("identity_changed"); + return record; + } + let mut shards = Vec::with_capacity(3); + for (index, shard) in hints.shards.iter().enumerate() { + let mut item = + json!({"ordinal":index+1,"outcome":"identity_changed","syscall_error":null}); + if hints.parent_unchanged() && shard.unchanged(&hints.root) { + if let Ok(bytes) = libc::off_t::try_from(shard.identity.bytes) { + match advise(&shard.file, bytes) { + Ok(()) => item["outcome"] = json!("advised"), + Err(error) => { + item["outcome"] = json!("error"); + item["syscall_error"] = json!(syscall_kind(error)); + } + } + } else { + item["outcome"] = json!("range_invalid"); + } + } + shards.push(item); + } + record["outcome"] = json!("shards_checked"); + record["shards"] = json!(shards); + if observe { + // One sequential observation, never an admission or reclaim proof. + record["after"] = sample(true).snapshot(); + } + record + } +} + +fn syscall_kind(error: nix::errno::Errno) -> &'static str { + match error { + nix::errno::Errno::EBADF => "bad_fd", + nix::errno::Errno::EINVAL => "invalid", + nix::errno::Errno::ENOSYS | nix::errno::Errno::EOPNOTSUPP => "unsupported", + nix::errno::Errno::EINTR => "interrupted", + _ => "other", + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/volparossa/src/compute/native_cpu/source_weight_cache/tests.rs b/crates/volparossa/src/compute/native_cpu/source_weight_cache/tests.rs new file mode 100644 index 000000000..3bffd5133 --- /dev/null +++ b/crates/volparossa/src/compute/native_cpu/source_weight_cache/tests.rs @@ -0,0 +1,629 @@ +//! Inert metadata/advice tests. Tiny fixtures do not prove real model execution. + +use std::{ + cell::Cell, + os::unix::fs::{PermissionsExt as _, symlink}, + time::{Duration, SystemTime}, +}; + +use nix::fcntl::{FcntlArg, fcntl}; + +use super::super::tests::fixture as native_fixture; +use super::*; + +fn specs() -> [ModelFileSpec; 3] { + let fixed: &[ModelFileSpec; 3] = PROFILE.sharded_weight_files().unwrap()[1..] + .try_into() + .unwrap(); + fixed.map(|spec| ModelFileSpec { bytes: 1, ..spec }) +} + +fn fixture() -> (tempfile::TempDir, Binding) { + let root = tempfile::tempdir().unwrap(); + fs::set_permissions(root.path(), fs::Permissions::from_mode(0o700)).unwrap(); + for spec in specs() { + fs::write(root.path().join(spec.name), b"x").unwrap(); + fs::set_permissions( + root.path().join(spec.name), + fs::Permissions::from_mode(0o600), + ) + .unwrap(); + } + let hints = Hints::bind(root.path(), &specs()).unwrap(); + ( + root, + Binding { + hints: Some(hints), + reason: "bound", + }, + ) +} + +fn model_result() -> Value { + let files = PROFILE.sharded_weight_files().unwrap(); + let model_files: serde_json::Map = files + .iter() + .map(|file| { + ( + file.name.to_owned(), + json!({"bytes":file.bytes,"sha256":file.sha256}), + ) + }) + .collect(); + json!({"model":{"files":model_files,"weights":{ + "layout":"safetensors_shards_concat_v1", + "bytes":PROFILE.spec().weights_bytes,"sha256":PROFILE.spec().weights_sha256, + "files":files[1..].iter().map(|file|file.name).collect::>() + }}}) +} + +fn observe(binding: Binding, result: &Result) -> Value { + binding.observe_with( + result, + true, + |_| memory::Sample::for_test(Some(0)), + |_, _| Ok(()), + ) +} + +#[test] +fn source_binding_is_native_owner_authorized_and_exactly_three_fixed_shards() { + let (_native, mut options) = native_fixture(); + let request = options.request(PROFILE).unwrap(); + let (root, _small) = fixture(); + // Sparse length metadata only; no allocation/read of model bytes or model claim. + for spec in &PROFILE.sharded_weight_files().unwrap()[1..] { + OpenOptions::new() + .write(true) + .open(root.path().join(spec.name)) + .unwrap() + .set_len(spec.bytes) + .unwrap(); + } + let binding = Binding::bind(&request, &options, PROFILE, root.path()); + let hints = binding.hints.unwrap(); + assert_eq!(hints.shards.len(), 3); + for (shard, spec) in hints + .shards + .iter() + .zip(&PROFILE.sharded_weight_files().unwrap()[1..]) + { + assert_eq!(shard.name, spec.name); + assert_eq!(shard.identity.bytes, spec.bytes); + } + assert!(!root.path().join("model.safetensors.index.json").exists()); + assert!( + Binding::bind(&request, &options, ModelProfile::Qwen600, root.path()) + .hints + .is_none() + ); + assert!( + Binding::bind(&request, &Options::default(), PROFILE, root.path()) + .hints + .is_none() + ); + options.native_backend_sha256 = Some("0".repeat(64)); + assert!( + Binding::bind(&request, &options, PROFILE, root.path()) + .hints + .is_none() + ); + let legacy = Options::default().request(PROFILE).unwrap(); + assert!( + Binding::bind(&legacy, &options, PROFILE, root.path()) + .hints + .is_none() + ); +} + +#[test] +fn source_descriptors_are_readonly_cloexec_nonblocking_and_exact() { + let (root, binding) = fixture(); + let hints = binding.hints.unwrap(); + assert!(hints.parent_unchanged()); + for shard in hints.shards { + assert!(shard.unchanged(root.path())); + assert_ne!( + fcntl(&shard.file, FcntlArg::F_GETFD).unwrap() & libc::FD_CLOEXEC, + 0 + ); + let flags = fcntl(&shard.file, FcntlArg::F_GETFL).unwrap(); + assert_eq!(flags & libc::O_ACCMODE, libc::O_RDONLY); + assert_ne!(flags & libc::O_NONBLOCK, 0); + assert_ne!(flags & libc::O_NOFOLLOW, 0); + } +} + +#[test] +fn source_identity_rejects_foreign_owner_permissions_links_type_and_size() { + let (_root, binding) = fixture(); + let identity = binding.hints.unwrap().shards[0].identity; + assert!(identity.valid(1)); + assert!(!identity.valid(2)); + for changed in [ + Identity { + owner: identity.owner.wrapping_add(1), + ..identity + }, + Identity { + mode: identity.mode | 0o040, + ..identity + }, + Identity { + mode: identity.mode | 0o004, + ..identity + }, + Identity { + mode: libc::S_IFDIR | 0o600, + ..identity + }, + Identity { + links: 2, + ..identity + }, + Identity { + bytes: 0, + ..identity + }, + Identity { + bytes: 5 * 1024 * 1024 * 1024, + ..identity + }, + ] { + assert!(!changed.valid(changed.bytes)); + } +} + +#[test] +fn source_bind_rejects_symlink_hardlink_nonregular_and_unprivate_root() { + for change in 0..6 { + let (root, _binding) = fixture(); + let path = root.path().join(specs()[1].name); + let original = root.path().join("original"); + match change { + 0 => { + fs::rename(&path, &original).unwrap(); + symlink("original", &path).unwrap(); + } + 1 => fs::hard_link(&path, &original).unwrap(), + 2 => { + fs::remove_file(&path).unwrap(); + fs::create_dir(&path).unwrap(); + } + 3 => fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(), + 4 => fs::write(&path, b"xx").unwrap(), + _ => fs::set_permissions(root.path(), fs::Permissions::from_mode(0o755)).unwrap(), + } + assert!(Hints::bind(root.path(), &specs()).is_err()); + } + let (root, _binding) = fixture(); + let alias = root.path().join("alias"); + symlink(root.path(), &alias).unwrap(); + assert!(Hints::bind(&alias, &specs()).is_err()); +} + +#[test] +fn source_changes_after_binding_skip_every_advice() { + for change in 0..6 { + let (root, binding) = fixture(); + let path = root.path().join(specs()[0].name); + match change { + 0 => { + fs::rename(&path, root.path().join("old")).unwrap(); + fs::write(&path, b"x").unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + } + 1 => fs::set_permissions(&path, fs::Permissions::from_mode(0o400)).unwrap(), + 2 => fs::hard_link(&path, root.path().join("other-link")).unwrap(), + 3 => fs::write(&path, b"xx").unwrap(), + 4 => binding.hints.as_ref().unwrap().shards[0] + .file + .set_modified(SystemTime::UNIX_EPOCH + Duration::from_secs(1)) + .unwrap(), + _ => fs::set_permissions(root.path(), fs::Permissions::from_mode(0o755)).unwrap(), + } + let record = binding.observe_with( + &Ok(model_result()), + true, + |_| memory::Sample::for_test(Some(0)), + |_, _| panic!("changed source advised"), + ); + assert_eq!(record["outcome"], "identity_changed"); + assert_eq!(record["shards"], json!([])); + assert!(record["after"].is_null()); + } +} + +#[test] +fn source_parent_replacement_cannot_rebind_retained_descriptors() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path().join("model"); + fs::create_dir(&root).unwrap(); + fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).unwrap(); + for spec in specs() { + fs::write(root.join(spec.name), b"x").unwrap(); + fs::set_permissions(root.join(spec.name), fs::Permissions::from_mode(0o600)).unwrap(); + } + let hints = Hints::bind(&root, &specs()).unwrap(); + let saved = directory.path().join("saved"); + fs::rename(&root, &saved).unwrap(); + fs::create_dir(&root).unwrap(); + fs::set_permissions(&root, fs::Permissions::from_mode(0o700)).unwrap(); + // Same retained files at the original lexical names, but a different parent. + for spec in specs() { + fs::rename(saved.join(spec.name), root.join(spec.name)).unwrap(); + } + assert!(!hints.parent_unchanged()); + let record = Binding { + hints: Some(hints), + reason: "bound", + } + .observe_with( + &Ok(model_result()), + true, + |_| memory::Sample::for_test(Some(0)), + |_, _| panic!("replaced parent advised"), + ); + assert_eq!(record["outcome"], "identity_changed"); +} + +#[test] +fn source_unbound_files_do_not_sample_or_advise() { + let record = Binding { + hints: None, + reason: "file_invalid", + } + .observe_with( + &Ok(model_result()), + true, + |_| panic!("unbound sample"), + |_, _| panic!("unbound advice"), + ); + assert_eq!(record["outcome"], "not_bound"); + assert_eq!(record["binding"], "file_invalid"); + assert_eq!(record["shards"], json!([])); + assert!(record["before"].is_null()); + assert!(record["after"].is_null()); +} + +#[test] +fn source_parent_change_during_advice_skips_remaining_descriptors() { + let (root, binding) = fixture(); + let calls = Cell::new(0); + let record = binding.observe_with( + &Ok(model_result()), + true, + |_| memory::Sample::for_test(Some(0)), + |_, _| { + calls.set(calls.get() + 1); + fs::set_permissions(root.path(), fs::Permissions::from_mode(0o755)).unwrap(); + Ok(()) + }, + ); + assert_eq!(calls.get(), 1); + assert_eq!(record["shards"][0]["outcome"], "advised"); + assert_eq!(record["shards"][1]["outcome"], "identity_changed"); + assert_eq!(record["shards"][2]["outcome"], "identity_changed"); +} + +#[test] +fn source_sufficient_after_gguf_never_uses_previous_shortage() { + let (_root, binding) = fixture(); + let required = resources::limits(PROFILE).admission_headroom.unwrap(); + let record = binding.observe_with( + &Ok(model_result()), + true, + |_| memory::Sample::for_test(Some(required)), + |_, _| panic!("stale shortage used"), + ); + assert_eq!(record["outcome"], "headroom_sufficient"); + assert_eq!(record["shards"], json!([])); + assert!(record["after"].is_null()); + assert!(resources::admits(PROFILE, Some(required))); +} + +#[test] +fn source_corrupted_report_pin_cannot_authorize_advice() { + let (_root, binding) = fixture(); + let mut report = model_result(); + report["model"]["files"][specs()[0].name]["sha256"] = json!("0".repeat(64)); + let record = binding.observe_with( + &Ok(report), + true, + |_| panic!("corrupted provenance sample"), + |_, _| panic!("corrupted provenance advice"), + ); + assert_eq!(record["outcome"], "provenance_unconfirmed"); + assert_eq!(record["shards"], json!([])); +} + +#[test] +fn source_binding_failure_keeps_finite_nonprivate_reason() { + let record = observe( + Binding { + hints: None, + reason: "open_failed", + }, + &Ok(model_result()), + ); + assert_eq!(record["binding"], "open_failed"); + assert_eq!(record["outcome"], "not_bound"); +} + +#[test] +fn source_advice_requires_successful_supervisor_and_checked_provenance() { + for outcome in ["supervisor_error", "provenance_unconfirmed"] { + let (_root, binding) = fixture(); + let result = if outcome == "supervisor_error" { + Err(private_task::CleanupUnconfirmed.into()) + } else { + Ok(json!({"model":{"files":{},"weights":{}}})) + }; + let record = binding.observe_with( + &result, + true, + |_| panic!("unverified sample"), + |_, _| panic!("unverified advice"), + ); + assert_eq!(record["outcome"], outcome); + assert!(record["before"].is_null()); + assert!(record["after"].is_null()); + assert_eq!(record["shards"], json!([])); + } +} + +#[test] +fn source_advice_requires_known_low_headroom_with_unchanged_admission() { + let required = resources::limits(PROFILE).admission_headroom.unwrap(); + assert_eq!(required, 11_274_289_152); + for (headroom, outcome) in [ + (None, "headroom_unknown"), + (Some(required), "headroom_sufficient"), + (Some(u64::MAX), "headroom_sufficient"), + ] { + let (_root, binding) = fixture(); + let samples = Cell::new(0); + let record = binding.observe_with( + &Ok(model_result()), + true, + |_| { + samples.set(samples.get() + 1); + memory::Sample::for_test(headroom) + }, + |_, _| panic!("no known shortage"), + ); + assert_eq!(record["outcome"], outcome); + assert_eq!(samples.get(), 1); + assert!(record["after"].is_null()); + assert_eq!(record["shards"], json!([])); + assert_eq!(resources::admits(PROFILE, headroom), headroom.is_some()); + } +} + +#[test] +fn source_one_shot_advises_exact_three_without_changing_original_result() { + let (_root, binding) = fixture(); + let identities = binding + .hints + .as_ref() + .unwrap() + .shards + .each_ref() + .map(|shard| shard.identity); + let result = Ok(model_result()); + let original = result.as_ref().unwrap().clone(); + let mut calls = 0; + let mut samples = 0; + let record = binding.observe_with( + &result, + true, + |stats| { + assert!(stats); + samples += 1; + memory::Sample::for_test(Some(0)) + }, + |file, bytes| { + assert_eq!(bytes, 1); + assert_eq!(Identity::read(&file.metadata().unwrap()), identities[calls]); + calls += 1; + Ok(()) + }, + ); + assert_eq!(calls, 3); + assert_eq!(samples, 2); + assert_eq!(record["outcome"], "shards_checked"); + assert_eq!( + record["shards"], + json!([ + {"ordinal":1,"outcome":"advised","syscall_error":null}, + {"ordinal":2,"outcome":"advised","syscall_error":null}, + {"ordinal":3,"outcome":"advised","syscall_error":null} + ]) + ); + assert_eq!(result.as_ref().ok(), Some(&original)); + assert!(!resources::admits(PROFILE, Some(0))); +} + +#[test] +fn source_syscall_failure_is_finite_without_retry_or_result_override() { + let (_root, binding) = fixture(); + let result = Ok(model_result()); + let original = result.as_ref().unwrap().clone(); + let calls = Cell::new(0); + let record = binding.observe_with( + &result, + true, + |_| memory::Sample::for_test(Some(0)), + |_, _| { + calls.set(calls.get() + 1); + Err(nix::errno::Errno::EINTR) + }, + ); + assert_eq!(calls.get(), 3); + for item in record["shards"].as_array().unwrap() { + assert_eq!(item["outcome"], "error"); + assert_eq!(item["syscall_error"], "interrupted"); + } + assert_eq!(result.as_ref().ok(), Some(&original)); + for (error, expected) in [ + (nix::errno::Errno::EBADF, "bad_fd"), + (nix::errno::Errno::EINVAL, "invalid"), + (nix::errno::Errno::ENOSYS, "unsupported"), + (nix::errno::Errno::EOPNOTSUPP, "unsupported"), + (nix::errno::Errno::EACCES, "other"), + ] { + assert_eq!(syscall_kind(error), expected); + } +} + +#[test] +fn source_rechecks_remaining_files_between_advisory_calls() { + let (root, binding) = fixture(); + let calls = Cell::new(0); + let record = binding.observe_with( + &Ok(model_result()), + true, + |_| memory::Sample::for_test(Some(0)), + |_, _| { + calls.set(calls.get() + 1); + if calls.get() == 1 { + fs::set_permissions( + root.path().join(specs()[1].name), + fs::Permissions::from_mode(0o400), + ) + .unwrap(); + } + Ok(()) + }, + ); + assert_eq!(calls.get(), 2); + assert_eq!(record["shards"][0]["outcome"], "advised"); + assert_eq!(record["shards"][1]["outcome"], "identity_changed"); + assert_eq!(record["shards"][2]["outcome"], "advised"); +} + +#[test] +fn source_observation_is_bounded_and_does_not_export_private_identities() { + let (root, binding) = fixture(); + let record = observe(binding, &Ok(model_result())); + let raw = serde_json::to_string(&record).unwrap(); + assert!("private_source_weight_cache_diagnostic ".len() + raw.len() < 4096); + assert!(!raw.contains(root.path().to_str().unwrap())); + for forbidden in [ + "safetensors", + "sha256", + "inode", + "owner", + "reclaimed", + "admitted", + ] { + assert!(!raw.contains(forbidden)); + } + assert_eq!(record.as_object().unwrap().len(), 8); + assert_eq!(record["version"], 1); + assert_eq!(record["phase"], "source_weights"); + assert_eq!(record["binding"], "bound"); + assert_eq!(record["supervisor_ok"], true); +} + +#[test] +fn source_disabled_observation_preserves_advice_but_no_extra_sampling() { + let (_root, binding) = fixture(); + let samples = Cell::new(0); + let record = binding.observe_with( + &Ok(model_result()), + false, + |stats| { + assert!(!stats); + samples.set(samples.get() + 1); + memory::Sample::for_test(Some(0)) + }, + |_, _| Ok(()), + ); + assert_eq!(samples.get(), 1); + assert!(record["before"].is_null()); + assert!(record["after"].is_null()); +} + +#[derive(Clone)] +struct Log(std::sync::Arc>>); + +impl std::io::Write for Log { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +#[test] +fn source_logging_requires_scope_and_its_own_explicit_debug_opt_in() { + for diagnostic in [false, true] { + for filter in [ + "off", + "off,volparossa::compute::private_diagnostic=debug", + "off,volparossa::compute::private_source_weight_cache_diagnostic=debug", + ] { + let log = Log(std::sync::Arc::default()); + let writer = log.clone(); + let subscriber = tracing_subscriber::fmt() + .without_time() + .with_ansi(false) + .with_target(false) + .with_level(false) + .with_env_filter(filter) + .with_writer(move || writer.clone()) + .finish(); + tracing::subscriber::with_default(subscriber, || { + let binding = Binding { + hints: None, + reason: "not_applicable", + }; + // Never invokes a real advisory or sampler in this logging test. + binding.after_supervisor(&Err(anyhow::anyhow!("PRIVATE_CANARY")), diagnostic); + }); + let raw = log.0.lock().unwrap(); + if diagnostic && filter.contains("private_source_weight_cache_diagnostic=") { + let text = std::str::from_utf8(&raw).unwrap(); + let record: Value = serde_json::from_str( + text.trim() + .strip_prefix("private_source_weight_cache_diagnostic ") + .unwrap(), + ) + .unwrap(); + assert_eq!(record["outcome"], "supervisor_error"); + assert_eq!(record["supervisor_ok"], false); + assert!(!text.contains("PRIVATE_CANARY")); + } else { + assert!(raw.is_empty()); + } + } + } +} + +#[test] +fn source_advice_stays_after_gguf_and_successful_join_inside_runtime_lease() { + let source = include_str!("../../../compute.rs"); + let lease = source.find("let _lease = runtime_lease").unwrap(); + let admission = source + .find("resources::admission(options.model_profile") + .unwrap(); + let binding = source.find("let source_weight_cache = ").unwrap(); + let spawn = source[binding..] + .find("let child = sandbox::command") + .unwrap() + + binding; + let join = source.find("let result = supervise::run(child").unwrap(); + let gguf = source.find("cache_hint.after_supervisor(&result").unwrap(); + let advice = source + .find("source_weight_cache.after_supervisor(&result") + .unwrap(); + assert!(lease < admission && admission < binding && binding < spawn); + assert!(spawn < join && join < gguf && gguf < advice); + assert!(source[advice..].starts_with( + "source_weight_cache.after_supervisor(&result, memory_diagnostic);\n result\n}" + )); +} diff --git a/crates/volparossa/src/compute/native_cpu/tests.rs b/crates/volparossa/src/compute/native_cpu/tests.rs new file mode 100644 index 000000000..a739da81c --- /dev/null +++ b/crates/volparossa/src/compute/native_cpu/tests.rs @@ -0,0 +1,298 @@ +//! Inert admission/binding fixtures only: no executable library or model is loaded. +use std::os::unix::fs::{PermissionsExt as _, symlink}; + +use clap::{CommandFactory as _, error::ErrorKind}; + +use super::*; + +mod native_session; +mod output_constraint; + +#[test] +fn native_flattened_options_keep_full_cli_argument_groups_unique() { + crate::Cli::command().debug_assert(); +} + +fn cli_arguments(operation: &str) -> Vec<&str> { + let mut arguments = vec![ + "volparossa", + "compute", + operation, + "--runtime-root", + "/runtime", + "--model-root", + "/model", + "--model-profile", + "qwen3-4b-instruct-2507-v1", + ]; + arguments.extend(match operation { + "run" => vec![ + "--mode", + "infer", + "--dataset", + "/dataset", + "--output", + "/output", + ], + "private-serve" => vec!["--work-parent", "/work", "--socket", "/private.sock"], + "private-task" => vec!["--input", "/input", "--work-parent", "/work"], + _ => panic!("unexpected inert CLI fixture"), + }); + arguments +} + +#[test] +fn native_cli_pair_requires_both_owner_flags_without_implicit_execution() { + // Parsing only: no path validation, library loading or model admission. + let hash = "a".repeat(64); + for operation in ["run", "private-serve"] { + let original = cli_arguments(operation); + for enabled in [false, true] { + let mut arguments = original.clone(); + if enabled { + arguments.extend([ + "--native-backend-root", + "/native", + "--native-backend-sha256", + &hash, + ]); + } + let parsed = crate::Cli::command() + .try_get_matches_from(arguments) + .unwrap(); + let options = parsed + .subcommand_matches("compute") + .unwrap() + .subcommand_matches(operation) + .unwrap(); + assert!(!options.get_flag("execute")); + assert_eq!( + options.get_one::("native_backend_root"), + enabled.then(|| PathBuf::from("/native")).as_ref() + ); + assert_eq!( + options.get_one::("native_backend_sha256"), + enabled.then_some(&hash) + ); + } + for half in [ + ["--native-backend-root", "/native"], + ["--native-backend-sha256", &hash], + ] { + let mut arguments = original.clone(); + arguments.extend(half); + let error = crate::Cli::command() + .try_get_matches_from(arguments) + .unwrap_err(); + assert_eq!(error.kind(), ErrorKind::MissingRequiredArgument); + } + } +} + +#[test] +fn private_task_keeps_native_backend_flags_unavailable() { + let original = cli_arguments("private-task"); + assert!( + crate::Cli::command() + .try_get_matches_from(original.clone()) + .is_ok() + ); + let hash = "a".repeat(64); + for added in [ + vec!["--native-backend-root", "/native"], + vec!["--native-backend-sha256", &hash], + vec![ + "--native-backend-root", + "/native", + "--native-backend-sha256", + &hash, + ], + ] { + let mut arguments = original.clone(); + arguments.extend(added); + let error = crate::Cli::command() + .try_get_matches_from(arguments) + .unwrap_err(); + assert_eq!(error.kind(), ErrorKind::UnknownArgument); + } +} + +fn manifest() -> Value { + json!({"version":1,"kind":KIND,"abi_version":1,"source_commit":SOURCE, + "model_profile":"qwen3-4b-instruct-2507-v1","source_weights_sha256":WEIGHTS, + "source_weights_bytes":8_044_982_000_u64, + "library":{"path":"libvolparossa_llama_cpu.so","sha256":"a".repeat(64),"bytes":1}, + "gguf":{"path":"model.gguf","sha256":"b".repeat(64),"bytes":1}, + "verification":{"tensor_count":398,"tensor_values_equal":true,"tokenizer_equal":true, + "chat_template_sha256":"c".repeat(64)},"build_manifest_sha256":"d".repeat(64)}) +} + +pub(super) fn fixture() -> (tempfile::TempDir, Options) { + let root = tempfile::tempdir().unwrap(); + fs::set_permissions(root.path(), fs::Permissions::from_mode(0o700)).unwrap(); + let raw = serde_json::to_vec(&manifest()).unwrap(); + fs::write(root.path().join("backend.json"), &raw).unwrap(); + for name in ["libvolparossa_llama_cpu.so", "model.gguf"] { + let path = root.path().join(name); + fs::write(&path, b"x").unwrap(); + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap(); + } + let options = Options { + native_backend_root: Some(root.path().to_owned()), + native_backend_sha256: Some(hex::encode(Sha256::digest(raw))), + }; + (root, options) +} + +#[test] +fn legacy_request_shape_is_unchanged_and_no_backend_report_is_accepted() { + let request = Options::default().request(ModelProfile::default()).unwrap(); + assert_eq!(serde_json::to_value(&request).unwrap(), json!({})); + assert!(!request.enabled()); + assert!(request.check_report(&json!({})).is_ok()); + assert!( + request + .check_report(&json!({"inference_backend":null})) + .is_err() + ); +} + +#[test] +fn manifest_requires_exact_source_profile_conversion_and_fixed_artifacts() { + let good = manifest(); + assert!( + serde_json::from_value::(good.clone()) + .unwrap() + .validate() + .is_ok() + ); + for (pointer, replacement) in [ + ("/kind", json!("peer_selected")), + ("/abi_version", json!(2)), + ("/source_commit", json!("0".repeat(40))), + ("/source_weights_sha256", json!("0".repeat(64))), + ("/source_weights_bytes", json!(1)), + ("/model_profile", json!("smollm2-360m-instruct-v1")), + ("/library/path", json!("../arbitrary.so")), + ("/gguf/path", json!("/other/model.gguf")), + ("/library/sha256", json!("A".repeat(64))), + ("/gguf/bytes", json!(10_u64 * 1024 * 1024 * 1024)), + ("/verification/tensor_count", json!(397)), + ("/verification/tensor_values_equal", json!(false)), + ("/verification/tokenizer_equal", json!(false)), + ] { + let mut bad = good.clone(); + *bad.pointer_mut(pointer).unwrap() = replacement; + assert!( + serde_json::from_value::(bad) + .and_then(|value| value.validate().map_err(serde::de::Error::custom)) + .is_err(), + "{pointer}" + ); + } + let mut extra = good; + extra["command"] = json!("arbitrary"); + assert!(serde_json::from_value::(extra).is_err()); +} + +#[test] +fn owner_authorization_binds_manifest_and_never_serializes_host_paths() { + let (_root, mut options) = fixture(); + let request = options.request(ModelProfile::Qwen4bInstruct2507).unwrap(); + let fields = serde_json::to_value(&request).unwrap(); + assert_eq!(fields.as_object().unwrap().len(), 3); + assert_eq!(fields["inference_backend"], KIND); + assert_eq!(fields["native_backend_root"], "/native-backend"); + assert!(options.request(ModelProfile::Qwen600).is_err()); + options.native_backend_sha256 = Some("0".repeat(64)); + assert!(options.request(ModelProfile::Qwen4bInstruct2507).is_err()); + options.native_backend_sha256 = None; + assert!(options.request(ModelProfile::Qwen4bInstruct2507).is_err()); +} + +#[test] +fn executable_symlink_shared_file_and_changed_length_are_rejected() { + let (root, options) = fixture(); + let path = root.path().join("libvolparossa_llama_cpu.so"); + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); + assert!(options.request(ModelProfile::Qwen4bInstruct2507).is_err()); + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + fs::write(&path, b"changed").unwrap(); + assert!(options.request(ModelProfile::Qwen4bInstruct2507).is_err()); + fs::remove_file(&path).unwrap(); + symlink(root.path().join("model.gguf"), path).unwrap(); + assert!(options.request(ModelProfile::Qwen4bInstruct2507).is_err()); +} + +#[test] +fn result_must_bind_authorized_native_identity_and_honest_precision() { + let (_root, options) = fixture(); + let request = options.request(ModelProfile::Qwen4bInstruct2507).unwrap(); + let good = json!({"inference_backend":request.expected_report, + "model_parameter_dtype":"bf16_with_exact_f32_norms","model_attention_backend":"llama_cpp_cpu"}); + assert!(request.check_report(&good).is_ok()); + for (pointer, replacement) in [ + ("/inference_backend/manifest_sha256", json!("0".repeat(64))), + ("/inference_backend/library_sha256", json!("0".repeat(64))), + ("/inference_backend/gguf_sha256", json!("0".repeat(64))), + ("/inference_backend/verification_scope/version", json!(true)), + ("/inference_backend/verification_scope/version", json!(2)), + ( + "/inference_backend/verification_scope/source_weights", + json!("before_and_after"), + ), + ( + "/inference_backend/verification_scope/execution_weights", + json!("initial_only"), + ), + ("/model_parameter_dtype", json!("bfloat16")), + ("/model_attention_backend", json!("sdpa")), + ] { + let mut bad = good.clone(); + *bad.pointer_mut(pointer).unwrap() = replacement; + assert!(request.check_report(&bad).is_err()); + } + let mut old = good.clone(); + old["inference_backend"] + .as_object_mut() + .unwrap() + .remove("verification_scope"); + assert!(request.check_report(&old).is_err()); + let mut extra = good; + extra["inference_backend"]["verification_scope"]["unverified"] = json!(true); + assert!(request.check_report(&extra).is_err()); +} + +#[test] +fn native_backend_requires_explicit_greedy_conversation_only() { + let (_root, options) = fixture(); + let mut input = json!({"version":1,"visibility":"private_local","instructions":"Reply.", + "history":[{"type":"message","role":"user","text":"Hello"}],"tools":[]}); + let profile = ModelProfile::Qwen4bInstruct2507; + assert!( + options + .validate_input( + Mode::PrivateConversation, + &serde_json::to_vec(&input).unwrap(), + profile + ) + .is_err() + ); + input["generation_policy"] = json!("greedy_v1"); + let bytes = serde_json::to_vec(&input).unwrap(); + assert!( + options + .validate_input(Mode::PrivateConversation, &bytes, profile) + .is_ok() + ); + assert!( + options + .validate_input(Mode::PublicCodeProposal, &bytes, profile) + .is_err() + ); + assert!( + options + .validate_input(Mode::PrivateConversation, &bytes, ModelProfile::Qwen600) + .is_err() + ); +} diff --git a/crates/volparossa/src/compute/native_cpu/tests/native_session.rs b/crates/volparossa/src/compute/native_cpu/tests/native_session.rs new file mode 100644 index 000000000..d74c11c12 --- /dev/null +++ b/crates/volparossa/src/compute/native_cpu/tests/native_session.rs @@ -0,0 +1,53 @@ +//! Manifest authorization only; no executable backend is loaded by these tests. + +use super::*; + +#[test] +fn native_session_capability_requires_owner_hash_and_exact_new_manifest() { + let (root, mut options) = fixture(); + let profile = ModelProfile::Qwen4bInstruct2507; + assert!(!options.supports_native_session(profile)); + let mut value = manifest(); + value["version"] = json!(3); + value["output_constraints"] = json!(["qwen_tagged_tool_json_v1"]); + value["native_sessions"] = json!(["connection_bound_v1"]); + let bytes = serde_json::to_vec(&value).unwrap(); + fs::write(root.path().join("backend.json"), &bytes).unwrap(); + assert!(!options.supports_native_session(profile)); + options.native_backend_sha256 = Some(hex::encode(Sha256::digest(&bytes))); + assert!(options.supports_native_session(profile)); + assert!(options.supports_output_constraint(profile)); + assert!(!options.supports_native_session(ModelProfile::Qwen600)); + let wire = serde_json::to_value(options.request(profile).unwrap()).unwrap(); + assert!(wire.get("native_session").is_none()); + assert!(wire.get("native_sessions").is_none()); +} + +#[test] +fn historical_manifests_cannot_claim_sessions_and_new_capability_is_closed() { + let mut modern = manifest(); + modern["version"] = json!(3); + modern["output_constraints"] = json!(["qwen_tagged_tool_json_v1"]); + modern["native_sessions"] = json!(["connection_bound_v1"]); + for invalid in [ + Value::Null, + json!([]), + json!(["other"]), + json!(["connection_bound_v1", "connection_bound_v1"]), + ] { + let mut bad = modern.clone(); + bad["native_sessions"] = invalid; + let decoded = serde_json::from_value::(bad); + assert!(decoded.is_err() || decoded.unwrap().validate().is_err()); + } + for version in [1, 2] { + let mut bad = modern.clone(); + bad["version"] = json!(version); + assert!( + serde_json::from_value::(bad) + .unwrap() + .validate() + .is_err() + ); + } +} diff --git a/crates/volparossa/src/compute/native_cpu/tests/output_constraint.rs b/crates/volparossa/src/compute/native_cpu/tests/output_constraint.rs new file mode 100644 index 000000000..94db9cf5e --- /dev/null +++ b/crates/volparossa/src/compute/native_cpu/tests/output_constraint.rs @@ -0,0 +1,122 @@ +use super::{super::super::private_conversation::Input, *}; + +const CONSTRAINT: &str = "qwen_tagged_tool_json_v1"; +const PROFILE: ModelProfile = ModelProfile::Qwen4bInstruct2507; + +fn constrained_manifest() -> Value { + let mut value = manifest(); + value["version"] = 2.into(); + value["output_constraints"] = json!([CONSTRAINT]); + value +} + +fn input() -> Vec { + serde_json::to_vec( + &json!({"version":1,"visibility":"private_local","instructions":"Reply.", + "history":[{"type":"message","role":"user","text":"Read."}], + "tools":[{"type":"custom","name":"PRIVATE_CANARY","description":"Read."}], + "generation_policy":"greedy_v1","output_constraint":CONSTRAINT}), + ) + .unwrap() +} + +fn constrained_fixture() -> (tempfile::TempDir, Options) { + let (root, mut options) = fixture(); + let raw = serde_json::to_vec(&constrained_manifest()).unwrap(); + fs::write(root.path().join("backend.json"), &raw).unwrap(); + options.native_backend_sha256 = Some(hex::encode(Sha256::digest(raw))); + (root, options) +} + +#[test] +fn manifest_version_binds_exact_constraint_capability_without_legacy_widening() { + for (version, constraints, accepted) in [ + (1, None, true), + (1, Some(Value::Null), false), + (1, Some(json!([])), false), + (2, None, false), + (1, Some(json!([CONSTRAINT])), false), + (2, Some(json!([CONSTRAINT])), true), + (3, Some(json!([CONSTRAINT])), false), + (2, Some(Value::Null), false), + (2, Some(json!([])), false), + (2, Some(json!([CONSTRAINT, CONSTRAINT])), false), + (2, Some(json!(["other"])), false), + (2, Some(json!([true])), false), + ] { + let mut value = manifest(); + value["version"] = version.into(); + if let Some(constraints) = constraints { + value["output_constraints"] = constraints; + } + let valid = serde_json::from_value::(value) + .is_ok_and(|manifest| manifest.validate().is_ok()); + assert_eq!(valid, accepted); + } +} + +#[test] +fn constrained_worker_request_requires_authorized_manifest_and_core_aliases() { + let raw = input(); + let (_old_root, legacy) = fixture(); + assert!(!legacy.supports_output_constraint(PROFILE)); + assert!( + legacy + .validate_input(Mode::PrivateConversation, &raw, PROFILE) + .is_err() + ); + assert!( + Options::default() + .validate_input(Mode::PrivateConversation, &raw, PROFILE) + .is_err() + ); + let (root, mut options) = constrained_fixture(); + assert!(options.supports_output_constraint(PROFILE)); + assert!(!options.supports_output_constraint(ModelProfile::Qwen600)); + options + .validate_input(Mode::PrivateConversation, &raw, PROFILE) + .unwrap(); + let dataset = root.path().join("input.json"); + fs::write(&dataset, raw).unwrap(); + let request = options + .request_for_dataset(PROFILE, Mode::PrivateConversation, &dataset) + .unwrap(); + let value = serde_json::to_value(&request).unwrap(); + assert_eq!(value.as_object().unwrap().len(), 5); + assert_eq!(value["output_constraint"], CONSTRAINT); + assert_eq!(value["output_constraint_tools"], json!(["vp_0"])); + assert!(!value.to_string().contains("PRIVATE_CANARY")); + assert!(!value.to_string().contains(root.path().to_str().unwrap())); + options.native_backend_sha256 = Some("0".repeat(64)); + assert!(!options.supports_output_constraint(PROFILE)); +} + +#[test] +fn support_does_not_select_constraint_and_result_cannot_claim_unrequested_support() { + let (_root, options) = constrained_fixture(); + let mut request = options.request(PROFILE).unwrap(); + let legacy = serde_json::to_value(&request).unwrap(); + assert_eq!(legacy.as_object().unwrap().len(), 3); + let mut result = json!({"inference_backend":request.expected_report, + "model_parameter_dtype":"bf16_with_exact_f32_norms","model_attention_backend":"llama_cpp_cpu"}); + request.check_report(&result).unwrap(); + result["output_constraint"] = CONSTRAINT.into(); + assert!(request.check_report(&result).is_err()); + let input = Input::decode_profile(&input(), PROFILE).unwrap(); + request.select_constraint(&input).unwrap(); + request.check_report(&result).unwrap(); + let mut extra = result.clone(); + extra["output_constraint_tools"] = json!(["vp_0"]); + assert!(request.check_report(&extra).is_err()); + for invalid in [Value::Null, json!(false), json!("other")] { + result["output_constraint"] = invalid; + assert!(request.check_report(&result).is_err()); + } + result.as_object_mut().unwrap().remove("output_constraint"); + assert!(request.check_report(&result).is_err()); + let value = serde_json::to_value(&request).unwrap(); + let mut restored = value.as_object().unwrap().clone(); + restored.remove("output_constraint"); + restored.remove("output_constraint_tools"); + assert_eq!(Value::Object(restored), legacy); +} diff --git a/crates/volparossa/src/compute/peer/document.rs b/crates/volparossa/src/compute/peer/document.rs index 4402e2fa1..96945b640 100644 --- a/crates/volparossa/src/compute/peer/document.rs +++ b/crates/volparossa/src/compute/peer/document.rs @@ -689,6 +689,7 @@ async fn tokenize( "compute_document_cancelled_before_planning" ); let options = super::super::Options { + native_backend: crate::compute::native_cpu::Options::default(), mode: Mode::PlanDocument, model_profile: input.model_profile, runtime_root: args diff --git a/crates/volparossa/src/compute/peer/document/graph/planner.rs b/crates/volparossa/src/compute/peer/document/graph/planner.rs index 374a21fdf..e36844d10 100644 --- a/crates/volparossa/src/compute/peer/document/graph/planner.rs +++ b/crates/volparossa/src/compute/peer/document/graph/planner.rs @@ -196,6 +196,7 @@ pub(super) async fn prepare( task::write_bytes(&args.directory.join("planner-input.json"), &bytes, false)?; let output = args.directory.join("model-planner"); let options = compute::Options { + native_backend: compute::native_cpu::Options::default(), mode: compute::Mode::PlanTasks, model_profile: args.model_profile, runtime_root: args diff --git a/crates/volparossa/src/compute/private_conversation.rs b/crates/volparossa/src/compute/private_conversation.rs index fdb4dcde4..f532f3667 100644 --- a/crates/volparossa/src/compute/private_conversation.rs +++ b/crates/volparossa/src/compute/private_conversation.rs @@ -28,6 +28,12 @@ pub(super) struct Input { deserialize_with = "present_generation_policy" )] generation_policy: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "present_output_constraint" + )] + output_constraint: Option, } #[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)] @@ -43,6 +49,12 @@ fn present_generation_policy<'de, D: serde::Deserializer<'de>>( GenerationPolicy::deserialize(deserializer).map(Some) } +fn present_output_constraint<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> std::result::Result, D::Error> { + super::native_cpu::OutputConstraint::deserialize(deserializer).map(Some) +} + /// The supervisor must not bind a result after silently accepting duplicate keys. pub(super) fn decode_worker_message(raw: &[u8]) -> Result { #[derive(Deserialize)] @@ -139,6 +151,24 @@ fn arguments(value: &Value) -> bool { value.is_object() && serde_json::to_vec(value).is_ok_and(|raw| raw.len() <= 4096) } +fn constrained_argument_depth(value: &Value, depth: u8) -> bool { + match value { + Value::Object(values) => { + depth <= 16 + && values + .values() + .all(|value| constrained_argument_depth(value, depth + 1)) + } + Value::Array(values) => { + depth <= 16 + && values + .iter() + .all(|value| constrained_argument_depth(value, depth + 1)) + } + _ => true, + } +} + impl Tool { fn key(&self) -> (&str, Option<&str>) { match self { @@ -180,6 +210,18 @@ impl Tool { } impl Input { + pub(super) fn output_constraint(&self) -> Option { + self.output_constraint + } + + pub(super) fn output_constraint_tools(&self) -> Option> { + self.output_constraint.map(|_| { + (0..self.tools.len()) + .map(|index| format!("vp_{index}")) + .collect() + }) + } + pub(super) fn requires_generation_policy_handshake(&self) -> bool { self.generation_policy.is_some() } @@ -217,6 +259,13 @@ impl Input { fn validate_profile(&self, profile: ModelProfile) -> Result<()> { let bounds = limits::for_profile(profile); + ensure!( + self.output_constraint.is_none() + || (profile == ModelProfile::Qwen4bInstruct2507 + && self.generation_policy == Some(GenerationPolicy::GreedyV1) + && (1..=32).contains(&self.tools.len())), + "conversation_output_constraint" + ); ensure!( self.generation_policy.is_none() || profile.is_native_conversation(), "conversation_generation_policy" @@ -333,7 +382,12 @@ impl Input { arguments: args, } => { self.call(call_id, name, namespace.as_deref(), false, &seen)?; - ensure!(arguments(args), "conversation_arguments"); + ensure!( + arguments(args) + && (self.output_constraint.is_none() + || constrained_argument_depth(args, 1)), + "conversation_arguments" + ); } Output::CustomToolCall { call_id, @@ -401,6 +455,11 @@ pub(super) fn validate_report(report: &Value, raw: &[u8], profile: ModelProfile) report.get("generation_policy") == expected_policy.as_ref(), "conversation_generation_policy_binding" ); + let expected_constraint = input.output_constraint.map(|constraint| json!(constraint)); + ensure!( + report.get("output_constraint") == expected_constraint.as_ref(), + "conversation_output_constraint_binding" + ); let expected = if profile.is_native_conversation() { qwen::turn( &input, @@ -445,6 +504,9 @@ pub(super) fn summary(report: &Value, profile: ModelProfile) -> Value { if let Some(policy) = report.get("generation_policy") { result["generation_policy"] = policy.clone(); } + if let Some(constraint) = report.get("output_constraint") { + result["output_constraint"] = constraint.clone(); + } result } diff --git a/crates/volparossa/src/compute/private_conversation/WIRE.md b/crates/volparossa/src/compute/private_conversation/WIRE.md index 1e38fec8f..e56ee285c 100644 --- a/crates/volparossa/src/compute/private_conversation/WIRE.md +++ b/crates/volparossa/src/compute/private_conversation/WIRE.md @@ -134,10 +134,60 @@ not retry a failed generation or disclose raw parser/backend diagnostics. When explicitly enabled, the `volparossa::compute::private_diagnostic` debug target records a fixed native-output rejection category only after the complete report binding passes. It never includes raw output, tool names or arguments; -the wire reply remains `invalid_output`. These events observe report validation, +the wire reply remains `invalid_output`. JSON rejection uses only Serde's closed +`syntax`, `data`, `eof` or `io` category, emitted as +`conversation_native_json_{category}`. No error message, field name, path, line or +column is exported. `data` includes schema/type and duplicate-key rejection; +`eof` is an incomplete JSON body, not proof of a token or transport limit. `io` +is retained for exhaustive classification, not a claim that the current +string-backed parser performs I/O. Historical `conversation_native_json` records +remain readable, but cannot retrospectively be assigned a finer category. +These events observe report validation, not model attempts: validating the same bound report again may repeat its code without another execution. Request/result counters track actual attempts. +## Connection-bound native sessions + +The explicit Qwen4B native backend can retain its model and decoded prompt +prefix within one authenticated owner connection. Negotiate +`native_session_version:1` together with `generation_policy_version:1` in +`conversation_capabilities`. Only that handshake adds `native_session_version:1` +and `native_session` to the capabilities. The latter is either null (unsupported) +or exactly `{version:1,max_task_seconds:2400,max_idle_seconds:300,max_requests:128}`. +Support requires the owner-bound backend v3/build v4 manifest with +`native_sessions:["connection_bound_v1"]`. This is not a bearer session identifier +or permission to share private state with another connection. + +Send `{type:"open_native_session"}` after negotiation. The core reserves the +shared execution slot and returns `native_session_opened` with the open ID; +model preparation starts only after the first admitted `submit_conversation`. +Submissions retain their ordinary input, tool, generation-policy and output- +constraint validation. A successful inner result has `version:2` and replaces +only the legacy cleanup member with +`{version:2,request_files_removed:true,session_closed:false,session_private_state_retained:true}`. +The envelope stays version 1. This result proves neither worker exit nor final +private-state removal. Legacy clients cannot interpret it as a v1 result. + +Send `{type:"close_native_session"}` to end the owner task. Only after joined +worker/descendant cleanup and private staging removal does the core return +`native_session_closed` with the close ID and +`cleanup:{version:1,child_reaped:true,private_state_removed:true}`, followed by EOF. +Automatic idle or lifetime closure uses the original open ID. Terminal native +errors carry `session_cleanup` with the same confirmed record or null when +cleanup is uncertain. They correlate to the pending request, requested close or +original open, respectively. Cancellation uses the existing task ID; its immediate +acknowledgement is not a cleanup receipt. Uncertain cleanup quarantines the slot. + +The core keeps the 600-second request ceiling, including preparation, generation +and the internal end-of-turn handshake. It caps the owner lifetime at 2,400 seconds, +idle time at 300 seconds and submissions at 128. Each request retains its CPU +allowance; the resident process has a separate cumulative task ceiling. Memory, +pressure and owner-activity checks continue while resident, including idle time. +Exact matching decoded tokens may be reused, with the final prompt token +recomputed; no prompt truncation, model substitution or shared KV cache is added. +Final cleanup is process/staging cleanup, not a claim of forensic RAM erasure. +Real acceleration and a complete coding task still require actual-model evidence. + ## Explicit limits Capabilities contain the same model/limits facts as result `limits`, plus @@ -228,11 +278,32 @@ its weight length is 8,044,982,000 bytes. It is not the index's hash, nor the index's tensor-size metadata. Worker `model.files` retains the real index and shard names. Only this profile adds `model.weights` with layout `safetensors_shards_concat_v1`, aggregate `bytes`/`sha256`, and ordered `files`. -Provisioning and the worker verify every original shard plus the aggregate; the -worker verifies again after generation and the Rust supervisor checks the exact -reported asset set/identity. Single-file profile reports remain unchanged. - -Candidate resources are CPU BF16/SDPA, at most two threads, 600 seconds per task, +Provisioning and the worker verify every original shard plus the aggregate. The +PyTorch backend verifies those execution weights again after generation; its +behavior and single-file profile reports remain unchanged. The Rust supervisor +checks the exact reported asset set/identity for both backends. + +For the explicit `llama_cpp_bf16_v1` backend, the original shards are conversion +source provenance, not the weights read by native inference. The worker carries +the actual complete initial shard/aggregate measurement into `model.weights`; +it does not reconstruct that measurement from expected constants or claim a +second source-shard verification at the end. The owner-bound converted GGUF is +fully hashed before and after each native generation. A one-shot execution closes +its handle before the final hash; a connection-bound session retains that handle +until session cleanup and still performs the complete per-request hashes. +Converter tensor-value equality, original model identity, native complete tensor +validation and library/build/manifest authorization remain required. + +The internal native `inference_backend` report must now include exactly +`verification_scope: {"version":1,"source_weights":"initial_complete_bytes_only", +"execution_weights":"gguf_complete_bytes_before_and_after"}`. The core rejects +missing, unknown or different scope, including old worker reports without this +field; there is no permissive interpretation or fallback. This intentionally +changes the internal native verification contract, not the public conversation +schema. It does not attest continuous filesystem immutability against the local +owner, successful inference, or private execution on other nodes. + +Candidate resources are CPU BF16/SDPA, at most two threads, 600 seconds per request, 10GiB observed RSS, 24GiB address space and 10.5GiB known spare memory before launch. These are admission/stop limits, not measured adequacy or a no-impact guarantee. The planned disposable comparison uses a 12GiB guest with an 11GiB diff --git a/crates/volparossa/src/compute/private_conversation/qwen.rs b/crates/volparossa/src/compute/private_conversation/qwen.rs index 9d6e8522b..626167443 100644 --- a/crates/volparossa/src/compute/private_conversation/qwen.rs +++ b/crates/volparossa/src/compute/private_conversation/qwen.rs @@ -106,6 +106,18 @@ fn checked_parse(input: &Input, raw: &str, id: &str) -> Result { Ok(value) } +fn json_rejection_code(error: &serde_json::Error) -> &'static str { + // Never format the error: it may contain private field names or values. + // The current string parser cannot produce I/O errors, but keep the + // exhaustive category closed without treating that case as success. + match error.classify() { + serde_json::error::Category::Syntax => "conversation_native_json_syntax", + serde_json::error::Category::Data => "conversation_native_json_data", + serde_json::error::Category::Eof => "conversation_native_json_eof", + serde_json::error::Category::Io => "conversation_native_json_io", + } +} + /// A closed local observation, used only after the complete worker report has /// been independently bound to this exact input/output. Never export the parser /// error: JSON diagnostics can contain private tool names or argument text. @@ -116,8 +128,8 @@ pub(super) fn rejection_code(input: &Input, output: &Value, id: &str) -> &'stati let Err(error) = checked_parse(input, raw, id) else { return "conversation_native_output_other"; }; - if error.is::() { - return "conversation_native_json"; + if let Some(error) = error.downcast_ref::() { + return json_rejection_code(error); } match error.to_string().as_str() { "conversation_request_id" => "conversation_request_id", @@ -151,3 +163,16 @@ pub(super) fn turn(input: &Input, output: &Value, id: &str) -> Result { None => Ok(incomplete("invalid_output")), } } + +#[cfg(test)] +mod tests { + #[test] + fn json_io_category_does_not_format_private_error_text() { + // Inert classification only; the production from_str path performs no I/O. + let error = serde_json::Error::io(std::io::Error::other("/PRIVATE_CANARY/field")); + assert_eq!( + super::json_rejection_code(&error), + "conversation_native_json_io" + ); + } +} diff --git a/crates/volparossa/src/compute/private_conversation/tests.rs b/crates/volparossa/src/compute/private_conversation/tests.rs index 338c20cf3..ca94c7179 100644 --- a/crates/volparossa/src/compute/private_conversation/tests.rs +++ b/crates/volparossa/src/compute/private_conversation/tests.rs @@ -1,6 +1,8 @@ //! Admission/report tests only; none assert model inference or tool-use quality. use super::*; +mod output_constraint; + fn input() -> Value { json!({"version":1,"visibility":"private_local","instructions":"Review the supplied code.", "tools":[{"type":"function","name":"read_file","description":"Read an approved file.", @@ -494,6 +496,68 @@ fn qwen_native_preface_preserves_exact_call_and_rejects_ambiguous_framing() { } } +#[test] +fn qwen_json_categories_preserve_strict_turns_and_literal_argument_delimiters() { + let value = input(); + let id = "ab".repeat(16); + for profile in [ModelProfile::Qwen600, ModelProfile::Qwen4bInstruct2507] { + let input = Input::decode_profile(&serde_json::to_vec(&value).unwrap(), profile).unwrap(); + for (body, code) in [ + ( + r#"{"name":"vp_0","arguments":{"PRIVATE_CANARY":!}}"#, + "conversation_native_json_syntax", + ), + ( + r#"{"name":false,"arguments":{"PRIVATE_CANARY":0}}"#, + "conversation_native_json_data", + ), + ( + r#"{"name":"vp_0","arguments":{},"PRIVATE_CANARY":0}"#, + "conversation_native_json_data", + ), + ( + r#"{"name":"vp_0","arguments":{"PRIVATE_CANARY":0,"PRIVATE_CANARY":1}}"#, + "conversation_native_json_data", + ), + ( + r#"{"name":"vp_0","arguments":{"PRIVATE_CANARY":"unfinished"#, + "conversation_native_json_eof", + ), + ] { + let output = native_output(&format!("{body}")); + let turn = qwen::turn(&input, &output, &id).unwrap(); + assert_eq!(turn, json!({"type":"incomplete","reason":"invalid_output"})); + assert_eq!(qwen::rejection_code(&input, &output, &id), code); + assert!(!code.contains("PRIVATE_CANARY")); + let report = json!({"id":id,"outputs":[output],"conversation":turn, + "prompt_tokens":128,"conversation_limits":capabilities(profile)}); + validate_report(&report, &serde_json::to_vec(&value).unwrap(), profile).unwrap(); + } + let literal = r#"{"name":"vp_0","arguments":{"text":"PRIVATE_CANARY and "}}"#; + let output = native_output(literal); + assert_eq!( + qwen::turn(&input, &output, &id).unwrap()["arguments"], + json!({"text":"PRIVATE_CANARY and "}) + ); + assert_eq!( + qwen::rejection_code(&input, &output, &id), + "conversation_native_output_other" + ); + let mut partial = native_output("{"); + partial["generation"]["stop_reason"] = "token_limit".into(); + partial["generated_tokens"] = 1024.into(); + assert_eq!( + qwen::turn(&input, &partial, &id).unwrap()["reason"], + "token_limit" + ); + partial["text_truncated"] = true.into(); + assert_eq!( + qwen::turn(&input, &partial, &id).unwrap()["reason"], + "wire_truncated" + ); + } +} + #[test] fn qwen_rejection_diagnostics_are_closed_and_keep_incomplete_wire_output() { let mut value = input(); @@ -509,7 +573,7 @@ fn qwen_rejection_diagnostics_are_closed_and_keep_incomplete_wire_output() { ), ( r#"{"PRIVATE_CANARY":0}"#, - "conversation_native_json", + "conversation_native_json_data", ), ( r#"{"name":"PRIVATE_CANARY","arguments":{}}"#, @@ -619,7 +683,7 @@ fn qwen_diagnostic_requires_bound_report_and_explicit_private_debug_target() { assert_eq!( serde_json::from_str::(record.trim()).unwrap(), json!({"version":1,"phase":"execution", - "detail":{"code":"conversation_native_json","io_kind":"none","exit_code":null,"signal":null,"stderr_class":null}}) + "detail":{"code":"conversation_native_json_data","io_kind":"none","exit_code":null,"signal":null,"stderr_class":null}}) ); } } diff --git a/crates/volparossa/src/compute/private_conversation/tests/output_constraint.rs b/crates/volparossa/src/compute/private_conversation/tests/output_constraint.rs new file mode 100644 index 000000000..cdb16ddf1 --- /dev/null +++ b/crates/volparossa/src/compute/private_conversation/tests/output_constraint.rs @@ -0,0 +1,191 @@ +//! Pure wire and original-text validation; no model execution or grammar quality claim. +use super::*; + +const CONSTRAINT: &str = "qwen_tagged_tool_json_v1"; +const PROFILE: ModelProfile = ModelProfile::Qwen4bInstruct2507; + +fn selected() -> Value { + let mut value = input(); + value["tools"][0]["namespace"] = Value::Null; + value["generation_policy"] = "greedy_v1".into(); + value["output_constraint"] = CONSTRAINT.into(); + value +} + +fn decode_selected(value: &Value) -> Result { + Input::decode_profile(&serde_json::to_vec(value).unwrap(), PROFILE) +} + +fn report(text: &str) -> Value { + let mut output = native_output(text); + output["generation"]["model_profile"] = PROFILE.to_string().into(); + output["generation"]["max_new_tokens"] = PROFILE.spec().max_new_tokens.into(); + let input = decode_selected(&selected()).unwrap(); + let id = "ab".repeat(16); + json!({"id":id,"outputs":[output],"conversation":qwen::turn(&input,&output,&id).unwrap(), + "prompt_tokens":128,"conversation_limits":capabilities(PROFILE), + "generation_policy":"greedy_v1","output_constraint":CONSTRAINT}) +} + +#[test] +fn constraint_is_explicit_nonnull_qwen4b_greedy_with_offered_tools_only() { + let value = selected(); + let decoded = decode_selected(&value).unwrap(); + assert_eq!(decoded.output_constraint_tools(), Some(vec!["vp_0".into()])); + assert_eq!( + serde_json::from_slice::(&decoded.bytes_profile(PROFILE).unwrap()).unwrap(), + value + ); + for invalid in [ + Value::Null, + json!(true), + json!(1), + json!([]), + json!("other"), + ] { + let mut bad = value.clone(); + bad["output_constraint"] = invalid; + assert!(decode_selected(&bad).is_err()); + } + for profile in [ModelProfile::Smol360, ModelProfile::Qwen600] { + assert!(Input::decode_profile(&serde_json::to_vec(&value).unwrap(), profile).is_err()); + } + let mut no_policy = value.clone(); + no_policy + .as_object_mut() + .unwrap() + .remove("generation_policy"); + assert!(decode_selected(&no_policy).is_err()); + let mut no_tools = value.clone(); + no_tools["tools"] = json!([]); + assert!(decode_selected(&no_tools).is_err()); + let mut client_aliases = value; + client_aliases["output_constraint_tools"] = json!(["vp_0"]); + assert!(decode_selected(&client_aliases).is_err()); +} + +#[test] +fn constraint_aliases_are_core_derived_bounded_and_contiguous() { + let mut value = selected(); + let tool = value["tools"][0].clone(); + value["tools"] = (0..32) + .map(|index| { + let mut tool = tool.clone(); + tool["name"] = format!("private_tool_{index}").into(); + tool + }) + .collect::>() + .into(); + let aliases = decode_selected(&value) + .unwrap() + .output_constraint_tools() + .unwrap(); + assert_eq!( + aliases, + (0..32) + .map(|index| format!("vp_{index}")) + .collect::>() + ); + assert!( + !serde_json::to_string(&aliases) + .unwrap() + .contains("private_tool") + ); + let mut extra = tool; + extra["name"] = "extra".into(); + value["tools"].as_array_mut().unwrap().push(extra); + assert!(decode_selected(&value).is_err()); +} + +#[test] +fn absent_constraint_preserves_legacy_bytes_and_report_shape() { + let raw = br#"{"version":1,"visibility":"private_local","instructions":"Reply.","history":[{"type":"message","role":"user","text":"Hello"}],"tools":[],"generation_policy":"greedy_v1"}"#; + let input = Input::decode_profile(raw, PROFILE).unwrap(); + assert!(input.output_constraint().is_none()); + assert!(input.output_constraint_tools().is_none()); + assert_eq!(input.bytes_profile(PROFILE).unwrap(), raw.to_vec()); + let mut result = report("Plain answer."); + result.as_object_mut().unwrap().remove("output_constraint"); + validate_report(&result, raw, PROFILE).unwrap(); + assert!(summary(&result, PROFILE).get("output_constraint").is_none()); + result["output_constraint"] = CONSTRAINT.into(); + assert!(validate_report(&result, raw, PROFILE).is_err()); +} + +#[test] +fn constrained_result_is_exactly_bound_and_never_forces_a_tool() { + let raw = serde_json::to_vec(&selected()).unwrap(); + for text in [ + "Plain answer, without calling a tool.", + "{\"name\":\"vp_0\",\"arguments\":{\"name\":\"demo.rs\"}}", + ] { + let result = report(text); + validate_report(&result, &raw, PROFILE).unwrap(); + assert_eq!(summary(&result, PROFILE)["output_constraint"], CONSTRAINT); + for invalid in [Value::Null, json!(true), json!("other")] { + let mut bad = result.clone(); + bad["output_constraint"] = invalid; + assert!(validate_report(&bad, &raw, PROFILE).is_err()); + } + let mut absent = result; + absent.as_object_mut().unwrap().remove("output_constraint"); + assert!(validate_report(&absent, &raw, PROFILE).is_err()); + } +} + +#[test] +fn constraint_claim_does_not_repair_or_authorize_invalid_original_output() { + let raw = serde_json::to_vec(&selected()).unwrap(); + for text in [ + "{\"name\":\"vp_0\",\"arguments\":{broken}}", + "{\"name\":\"vp_31\",\"arguments\":{}}", + "{\"name\":\"vp_0\",\"arguments\":{},\"PRIVATE_CANARY\":1}", + ] { + let result = report(text); + assert_eq!(result["conversation"]["reason"], "invalid_output"); + validate_report(&result, &raw, PROFILE).unwrap(); + assert_eq!(summary(&result, PROFILE)["turn_complete"], false); + let mut forged = result; + forged["conversation"] = json!({"type":"assistant","text":"repaired"}); + assert!(validate_report(&forged, &raw, PROFILE).is_err()); + } +} + +#[test] +fn duplicate_constraint_keys_are_rejected_before_binding() { + let raw = serde_json::to_string(&selected()).unwrap(); + let field = format!("\"output_constraint\":\"{CONSTRAINT}\""); + let duplicate = raw.replace(&field, &format!("{field},{field}")); + assert!(Input::decode_profile(duplicate.as_bytes(), PROFILE).is_err()); + for value in ["null", "\"other\"", "\"qwen_tagged_tool_json_v1\""] { + let raw = format!("{{{field},\"output_constraint\":{value}}}"); + assert!(decode_worker_message(raw.as_bytes()).is_err()); + } +} + +#[test] +fn constrained_argument_nesting_limit_is_exact_and_does_not_change_legacy() { + let mut nested = json!({}); + for depth in 1..=17 { + let text = format!("{{\"name\":\"vp_0\",\"arguments\":{nested}}}"); + let result = report(&text); + assert_eq!( + result["conversation"]["type"] == "function_call", + depth <= 16 + ); + let mut legacy = selected(); + legacy.as_object_mut().unwrap().remove("output_constraint"); + let input = decode_selected(&legacy).unwrap(); + assert_eq!( + qwen::turn(&input, &result["outputs"][0], &"ab".repeat(16)).unwrap()["type"], + "function_call" + ); + nested = json!({"x":nested}); + } + let mut arrays = json!(0); + for _ in 0..15 { + arrays = json!([arrays]); + } + assert!(constrained_argument_depth(&json!({"x":arrays}), 1)); + assert!(!constrained_argument_depth(&json!({"x":[arrays]}), 1)); +} diff --git a/crates/volparossa/src/compute/private_serve.rs b/crates/volparossa/src/compute/private_serve.rs index ba115901e..4c8e3d85a 100644 --- a/crates/volparossa/src/compute/private_serve.rs +++ b/crates/volparossa/src/compute/private_serve.rs @@ -40,6 +40,8 @@ pub(crate) struct Options { work_parent: PathBuf, #[arg(long, default_value_t = ModelProfile::Smol360)] model_profile: ModelProfile, + #[command(flatten)] + native_backend: super::native_cpu::Options, #[arg(long, default_value_t = 2, value_parser = clap::value_parser!(u16).range(1..=2))] threads: u16, #[arg(long, default_value_t = 600, value_parser = clap::value_parser!(u16).range(1..=600))] @@ -59,6 +61,7 @@ impl Options { model_root: self.model_root.clone(), work_parent: self.work_parent.clone(), model_profile: self.model_profile, + native_backend: self.native_backend.clone(), threads: self.threads, max_seconds: self.max_seconds, } @@ -187,8 +190,7 @@ impl Active { let (activity, signal) = watch::channel(true); let execution = tokio::spawn(async move { let result = private_task::execute_mode(&config, input, signal, mode).await; - slot.finish(&result); - result + finish_execution(&mut slot, result) }); Self { id, @@ -217,6 +219,17 @@ impl Active { } } +fn finish_execution(slot: &mut ExecutionSlot, result: Result) -> Result { + slot.finish(&result); + if let Err(error) = &result { + // Cover failures before worker supervision and after report validation. + // The existing opt-in target emits only closed facts, never error text. + // This observation grants neither cleanup nor a different wire response. + super::supervise::diagnostic::failure("task", error); + } + result +} + impl Drop for Active { fn drop(&mut self) { // Dropping a connection never aborts/detaches ownership of the worker slot: @@ -236,17 +249,86 @@ fn capabilities(config: &private_task::ExecutionConfig, gate: &Semaphore) -> Val }) } +fn capabilities_response( + id: &str, + config: &private_task::ExecutionConfig, + gate: &Semaphore, +) -> Value { + let mut response = wire::response(id, "capabilities"); + response["capabilities"] = capabilities(config, gate); + if config.native_backend.enabled() { + response["capabilities"]["qa_supported"] = false.into(); + response["capabilities"]["inference_backend"] = super::native_cpu::KIND.into(); + } + response +} + enum Incoming { Request(wire::Request), Invalid, } -#[derive(Default)] +#[derive(Clone, Default)] struct Handshakes { qa: bool, conversation: bool, generation_policy: Option, execution_error: Option, + output_constraint: Option, + native_session: Option, +} + +fn conversation_capabilities( + config: &private_task::ExecutionConfig, + gate: &Semaphore, + handshakes: &Handshakes, +) -> Value { + let mut caps = super::private_conversation::capabilities(config.model_profile); + caps["execution_slots"] = 1.into(); + caps["max_seconds"] = config.max_seconds.into(); + caps["max_request_bytes"] = + super::private_conversation::request_frame(config.model_profile).into(); + caps["max_response_bytes"] = wire::MAX_RESPONSE_BYTES.into(); + caps["quarantined"] = gate.is_closed().into(); + if handshakes.execution_error == Some(1) { + caps["execution_error_version"] = 1.into(); + } + if handshakes.generation_policy == Some(1) { + caps["generation_policy_version"] = 1.into(); + caps["generation_policies"] = if config.model_profile.is_native_conversation() { + json!(["greedy_v1"]) + } else { + json!([]) + }; + } + if config.native_backend.enabled() { + caps["inference_backend"] = super::native_cpu::KIND.into(); + caps["required_generation_policy"] = "greedy_v1".into(); + } + if handshakes.output_constraint == Some(1) { + caps["output_constraint_version"] = 1.into(); + caps["output_constraints"] = if config + .native_backend + .supports_output_constraint(config.model_profile) + { + json!([super::native_cpu::OutputConstraint::QwenTaggedToolJsonV1]) + } else { + json!([]) + }; + } + if handshakes.native_session == Some(1) { + caps["native_session_version"] = 1.into(); + caps["native_session"] = if config + .native_backend + .supports_native_session(config.model_profile) + { + json!({"version":1,"max_task_seconds":2400,"max_idle_seconds":300, + "max_requests":128}) + } else { + Value::Null + }; + } + caps } fn respond( @@ -259,50 +341,43 @@ fn respond( match request.operation { wire::Operation::Capabilities {} => { handshakes.qa = true; - let mut response = wire::response(&request.id, "capabilities"); - response["capabilities"] = capabilities(config, gate); - response + capabilities_response(&request.id, config, gate) } wire::Operation::ConversationCapabilities { generation_policy_version, execution_error_version, + output_constraint_version, + native_session_version, } => { handshakes.conversation = true; handshakes.generation_policy = generation_policy_version; handshakes.execution_error = execution_error_version; + handshakes.output_constraint = output_constraint_version; + handshakes.native_session = native_session_version; let mut response = wire::response(&request.id, "conversation_capabilities"); - response["capabilities"] = - super::private_conversation::capabilities(config.model_profile); - response["capabilities"]["execution_slots"] = 1.into(); - response["capabilities"]["max_seconds"] = config.max_seconds.into(); - response["capabilities"]["max_request_bytes"] = - super::private_conversation::request_frame(config.model_profile).into(); - response["capabilities"]["max_response_bytes"] = wire::MAX_RESPONSE_BYTES.into(); - response["capabilities"]["quarantined"] = gate.is_closed().into(); - if handshakes.execution_error == Some(1) { - response["capabilities"]["execution_error_version"] = 1.into(); - } - if handshakes.generation_policy == Some(1) { - response["capabilities"]["generation_policy_version"] = 1.into(); - response["capabilities"]["generation_policies"] = - if config.model_profile.is_native_conversation() { - json!(["greedy_v1"]) - } else { - json!([]) - }; - } + response["capabilities"] = conversation_capabilities(config, gate, handshakes); response } wire::Operation::SubmitConversation { conversation } => { if !handshakes.conversation || (conversation.requires_generation_policy_handshake() && handshakes.generation_policy != Some(1)) + || (conversation.output_constraint().is_some() + && handshakes.output_constraint != Some(1)) { wire::error(Some(&request.id), "handshake_required") } else if gate.is_closed() { wire::error(Some(&request.id), "cleanup_unconfirmed") } else if active.is_some() { wire::error(Some(&request.id), "busy") + } else if (conversation.output_constraint().is_some() + && !config + .native_backend + .supports_output_constraint(config.model_profile)) + || (config.native_backend.enabled() + && !conversation.requires_generation_policy_handshake()) + { + wire::error(Some(&request.id), "invalid_request") } else if let Some(slot) = ExecutionSlot::admit(gate) { let input = conversation .bytes_profile(config.model_profile) @@ -326,6 +401,8 @@ fn respond( wire::Operation::Submit { question, context } => { if !handshakes.qa { wire::error(Some(&request.id), "handshake_required") + } else if config.native_backend.enabled() { + wire::error(Some(&request.id), "invalid_request") } else if gate.is_closed() { wire::error(Some(&request.id), "cleanup_unconfirmed") } else if active.is_some() { @@ -354,6 +431,9 @@ fn respond( wire::error(Some(&request.id), "no_such_task") } } + wire::Operation::OpenNativeSession {} | wire::Operation::CloseNativeSession {} => { + wire::error(Some(&request.id), "invalid_request") + } } } @@ -390,11 +470,325 @@ fn execution_budget_exceeded(error: &anyhow::Error) -> bool { .is_some_and(|failure| failure.code() == "JOB_DEADLINE_EXCEEDED") } +/// This lane is owned by one authenticated Unix connection, never by a UID or +/// a prompt-cache key. Its slot is released only after the entire session joins. +struct NativeSession { + open_id: String, + handshakes: Handshakes, + commands: mpsc::Sender, + events: mpsc::Receiver, + activity: watch::Sender, + execution: Option>>, + opened: bool, + pending: Option, + close_id: Option, + requests: usize, + cancelled: bool, + invalid_event: bool, +} + +impl NativeSession { + fn start( + open_id: String, + config: Arc, + slot: ExecutionSlot, + handshakes: Handshakes, + ) -> Self { + Self::spawn( + open_id, + slot, + handshakes, + move |commands, events, activity| { + super::supervise::native_session::run(config, commands, events, activity) + }, + ) + } + + fn spawn( + open_id: String, + mut slot: ExecutionSlot, + handshakes: Handshakes, + run: F, + ) -> Self + where + F: FnOnce( + mpsc::Receiver, + mpsc::Sender, + watch::Receiver, + ) -> Fut, + Fut: Future> + Send + 'static, + { + let (commands, receive) = mpsc::channel(1); + let (send, events) = mpsc::channel(1); + let (activity, signal) = watch::channel(true); + let execution = run(receive, send, signal); + let execution = tokio::spawn(async move { + let result = execution.await; + if native_cleanup(&result).is_none() { + // Quarantine before dropping the permit, not after scheduling the + // connection's terminal response: another connection may be ready. + slot.gate.close(); + } + finish_execution(&mut slot, result) + }); + Self { + open_id, + handshakes, + commands, + events, + activity, + execution: Some(execution), + opened: false, + pending: None, + close_id: None, + requests: 0, + cancelled: false, + invalid_event: false, + } + } + + fn cancel(&mut self) { + self.cancelled = true; + let _ = self.activity.send(false); + } + + fn request(&mut self, request: wire::Request) -> Option { + use super::supervise::native_session::Command; + match request.operation { + wire::Operation::SubmitConversation { conversation } => { + if !self.opened + || self.pending.is_some() + || self.close_id.is_some() + || self.cancelled + { + return Some(wire::error(Some(&request.id), "busy")); + } + if self.requests >= 128 + || !conversation.requires_generation_policy_handshake() + || (conversation.output_constraint().is_some() + && self.handshakes.output_constraint != Some(1)) + { + return Some(wire::error(Some(&request.id), "invalid_request")); + } + let input = conversation + .bytes_profile(ModelProfile::Qwen4bInstruct2507) + .expect("validated native conversation serializes"); + self.pending = Some(request.id.clone()); + if self + .commands + .try_send(Command::Submit { + id: request.id.clone(), + input, + }) + .is_err() + { + self.invalid_event = true; + self.cancel(); + return None; + } + self.requests += 1; + Some(wire::response(&request.id, "admitted")) + } + wire::Operation::CloseNativeSession {} => { + if self.close_id.is_some() { + return Some(wire::error(Some(&request.id), "busy")); + } + self.close_id = Some(request.id); + if self.pending.is_some() { + self.cancel(); + } else if self.commands.try_send(Command::Close).is_err() { + self.invalid_event = true; + self.cancel(); + } + None // A close receipt is emitted only after the session joins. + } + wire::Operation::Cancel { task_id } => { + if self.pending.as_deref() != Some(task_id.as_str()) { + return Some(wire::error(Some(&request.id), "no_such_task")); + } + self.cancel(); + let mut reply = wire::response(&request.id, "cancel_requested"); + reply["task_id"] = task_id.into(); + Some(reply) + } + _ => Some(wire::error(Some(&request.id), "busy")), + } + } + + fn event(&mut self, event: super::supervise::native_session::Event) -> Option { + use super::supervise::native_session::Event; + match event { + Event::Opened if !self.opened && !self.cancelled && self.close_id.is_none() => { + self.opened = true; + Some(wire::response(&self.open_id, "native_session_opened")) + } + Event::Result { id, value } if self.opened && self.pending.as_ref() == Some(&id) => { + if self.cancelled || self.close_id.is_some() { + return None; + } + // The runtime validates model output. This separate check prevents + // a per-turn result from masquerading as a child-reaped terminal. + if value["version"] != 2 + || value["cleanup"] + != json!({"version":2,"request_files_removed":true, + "session_closed":false,"session_private_state_retained":true}) + { + self.invalid_event = true; + self.cancel(); + return None; + } + self.pending = None; + let mut reply = wire::response(&id, "result"); + reply["result"] = value; + Some(reply) + } + _ => { + self.invalid_event = true; + self.cancel(); + None + } + } + } + + fn terminal( + &self, + result: std::result::Result, tokio::task::JoinError>, + gate: &Semaphore, + ) -> Vec { + let (cleanup, failed, deadline) = match result { + Ok(Ok(value)) => (Some(value), false, false), + Ok(Err(error)) => ( + super::supervise::native_session::cleanup(&error), + true, + super::supervise::native_session::execution_deadline(&error), + ), + Err(_) => (None, true, false), + }; + let cleanup = cleanup.filter(|value| { + *value == json!({"version":1,"child_reaped":true,"private_state_removed":true}) + }); + if cleanup.is_none() { + gate.close(); + } + let id = self + .pending + .as_ref() + .or(self.close_id.as_ref()) + .unwrap_or(&self.open_id); + if gate.is_closed() + || failed + || self.cancelled + || self.invalid_event + || self.pending.is_some() + { + let code = if gate.is_closed() { + "cleanup_unconfirmed" + } else if self.cancelled && !self.invalid_event { + "cancelled" + } else if deadline && self.handshakes.execution_error == Some(1) { + "execution_budget_exceeded" + } else { + "execution_failed" + }; + let mut reply = wire::error(Some(id), code); + reply["session_cleanup"] = cleanup.clone().unwrap_or(Value::Null); + let mut replies = vec![reply]; + if let Some(cleanup) = cleanup + .filter(|_| self.pending.is_some() && self.close_id.is_some() && !gate.is_closed()) + { + replies.push(self.closed(cleanup)); + } + return replies; + } + vec![self.closed(cleanup.expect("confirmed cleanup"))] + } + + fn closed(&self, cleanup: Value) -> Value { + let mut reply = wire::response( + self.close_id.as_ref().unwrap_or(&self.open_id), + "native_session_closed", + ); + reply["cleanup"] = cleanup; + reply + } +} + +fn native_cleanup(result: &Result) -> Option { + let value = match result { + Ok(value) => Some(value.clone()), + Err(error) => super::supervise::native_session::cleanup(error), + }; + value.filter(|value| { + *value == json!({"version":1,"child_reaped":true,"private_state_removed":true}) + }) +} + +impl Drop for NativeSession { + fn drop(&mut self) { + let _ = self.activity.send(false); + } +} + +type NativeSessionStart = + fn(String, Arc, ExecutionSlot, Handshakes) -> NativeSession; + +fn connection_request( + request: wire::Request, + config: &Arc, + gate: &Arc, + active: &mut Option, + handshakes: &mut Handshakes, + native: &mut Option, + start_native: NativeSessionStart, +) -> Option { + if let Some(session) = native { + return session.request(request); + } + if !matches!(request.operation, wire::Operation::OpenNativeSession {}) { + return Some(respond(request, config, gate, active, handshakes)); + } + if !handshakes.conversation + || handshakes.native_session != Some(1) + || handshakes.generation_policy != Some(1) + { + Some(wire::error(Some(&request.id), "handshake_required")) + } else if !config + .native_backend + .supports_native_session(config.model_profile) + { + Some(wire::error(Some(&request.id), "invalid_request")) + } else if gate.is_closed() { + Some(wire::error(Some(&request.id), "cleanup_unconfirmed")) + } else if active.is_some() { + Some(wire::error(Some(&request.id), "busy")) + } else if let Some(slot) = ExecutionSlot::admit(gate) { + *native = Some(start_native( + request.id, + config.clone(), + slot, + handshakes.clone(), + )); + None + } else { + Some(wire::error(Some(&request.id), "busy")) + } +} + async fn connection( + stream: UnixStream, + config: Arc, + gate: Arc, + shutdown: watch::Receiver, +) { + connection_with_native(stream, config, gate, shutdown, NativeSession::start).await; +} + +async fn connection_with_native( stream: UnixStream, config: Arc, gate: Arc, mut shutdown: watch::Receiver, + start_native: NativeSessionStart, ) { let (mut reader, mut writer) = stream.into_split(); let (send, mut receive) = mpsc::channel(1); @@ -419,7 +813,16 @@ async fn connection( let mut handshakes = Handshakes::default(); let mut seen = BTreeSet::new(); let mut active: Option = None; + let mut native: Option = None; + let mut native_events_open = true; loop { + // Borrow the disjoint channel/handle fields separately while select! + // polls them; no two futures may borrow the whole session mutably. + let (events, execution) = if let Some(session) = native.as_mut() { + (Some(&mut session.events), session.execution.as_mut()) + } else { + (None, None) + }; tokio::select! { biased; _ = shutdown.changed() => break, @@ -434,8 +837,32 @@ async fn connection( let _ = wire::write(&mut writer, &wire::error(Some(&request.id), "invalid_request")).await; break; } - let response = respond(request, &config, &gate, &mut active, &mut handshakes); - if wire::write(&mut writer, &response).await.is_err() { break; } + let response = connection_request(request, &config, &gate, &mut active, + &mut handshakes, &mut native, start_native); + if let Some(response) = response { + if wire::write(&mut writer, &response).await.is_err() { break; } + } + } + event = async { + events.expect("guarded native events").recv().await + }, if events.is_some() && native_events_open => { + if let Some(event) = event { + if let Some(response) = native.as_mut().expect("native session").event(event) { + if wire::write(&mut writer, &response).await.is_err() { break; } + } + } else { + native_events_open = false; + } + } + result = async { + execution.expect("guarded native execution").await + }, if execution.is_some() => { + let mut session = native.take().expect("completed native session"); + session.execution.take(); + for response in session.terminal(result, &gate) { + if wire::write(&mut writer, &response).await.is_err() { break; } + } + break; // A closed/failed owner session cannot be reused or reopened. } result = async { active.as_mut().expect("guarded active task").execution.as_mut().expect("active handle").await @@ -458,6 +885,13 @@ async fn connection( task.cancel(); let _ = task.reap().await; } + if let Some(mut session) = native { + session.cancel(); + if let Some(execution) = session.execution.take() { + // No detached worker after EOF, shutdown, invalid frame or write failure. + let _ = execution.await; + } + } } pub(super) async fn run(options: Options) -> Result<()> { diff --git a/crates/volparossa/src/compute/private_serve/WIRE.md b/crates/volparossa/src/compute/private_serve/WIRE.md index 97a762751..18a850aa7 100644 --- a/crates/volparossa/src/compute/private_serve/WIRE.md +++ b/crates/volparossa/src/compute/private_serve/WIRE.md @@ -83,6 +83,29 @@ Unconfirmed cleanup quarantines further admission. Temporary input/output files exist only under the owner's private work directory and follow the existing private-task cleanup contract; this is **not** a RAM-only or secure-erasure claim. +## Owner-selected native CPU backend + +The owner may explicitly supply both `--native-backend-root` and +`--native-backend-sha256` at service startup. The latter is the SHA-256 of the +private `backend.json` manifest binding the pinned library and converted model. +An IPC request cannot supply these paths or authorize executable code. The +backend directory is mounted read-only inside the existing isolated worker; +the worker verifies the complete artifact hashes before loading the library. + +This candidate supports only `qwen3-4b-instruct-2507-v1` conversations. Its +conversation capabilities add `inference_backend: "llama_cpp_bf16_v1"` and +`required_generation_policy: "greedy_v1"`; both fields are absent for the +unchanged default backend. Clients must negotiate `generation_policy_version: 1` +and explicitly submit `greedy_v1`. Missing policy is rejected before admission. +The existing Qwen4B wire validation rejects Q&A capabilities and submission; +the native backend does not enable a Q&A service. A conversation capability +response is not evidence that a model has run successfully. The public result +schema remains unchanged; the core binds +the worker's native artifact identity and reported precision internally. + +This is still same-owner local execution, not confidential execution on peers. +Actual conversion, full generation and application trials remain required. + ## Browser boundary Only privileged, owner-authorized browser code may use the socket. Web page scripts diff --git a/crates/volparossa/src/compute/private_serve/tests.rs b/crates/volparossa/src/compute/private_serve/tests.rs index 9c74c0cd1..c869a9728 100644 --- a/crates/volparossa/src/compute/private_serve/tests.rs +++ b/crates/volparossa/src/compute/private_serve/tests.rs @@ -4,10 +4,56 @@ use super::*; use tokio::io::AsyncWriteExt as _; +mod native_session; +mod output_constraint; +mod task_diagnostic; + const FIRST: &str = "01010101010101010101010101010101"; const SECOND: &str = "02020202020202020202020202020202"; const THIRD: &str = "03030303030303030303030303030303"; +#[test] +fn native_backend_advertises_restriction_and_rejects_unselected_policy_before_start() { + let root = tempfile::tempdir().unwrap(); + let mut config = config(root.path()); + config.model_profile = ModelProfile::Qwen4bInstruct2507; + // Admission-only fixture: no executable backend or model exists here. + config.native_backend.native_backend_root = Some(root.path().join("unused-native")); + config.native_backend.native_backend_sha256 = Some("a".repeat(64)); + let config = Arc::new(config); + let gate = Arc::new(Semaphore::new(1)); + let mut active = None; + let mut handshakes = Handshakes::default(); + for (operation, field, expected) in [ + (json!({"type":"capabilities"}), "qa_supported", json!(false)), + ( + json!({"type":"conversation_capabilities","generation_policy_version":1}), + "required_generation_policy", + json!("greedy_v1"), + ), + ] { + let request = serde_json::from_value(request(FIRST, operation)).unwrap(); + let reply = respond(request, &config, &gate, &mut active, &mut handshakes); + assert_eq!(reply["capabilities"][field], expected); + assert_eq!( + reply["capabilities"]["inference_backend"], + "llama_cpp_bf16_v1" + ); + } + for operation in [ + json!({"type":"submit","question":"Hello?","context":"Context"}), + json!({"type":"submit_conversation","conversation":{"version":1, + "visibility":"private_local","instructions":"Reply.","history":[ + {"type":"message","role":"user","text":"Hello"}],"tools":[]}}), + ] { + let request = serde_json::from_value(request(SECOND, operation)).unwrap(); + let reply = respond(request, &config, &gate, &mut active, &mut handshakes); + assert_eq!(reply, wire::error(Some(SECOND), "invalid_request")); + assert!(active.is_none()); + assert_eq!(gate.available_permits(), 1); + } +} + #[test] fn execution_error_negotiation_rejects_unknown_null_and_duplicate_versions() { for version in [ @@ -325,6 +371,7 @@ fn request(id: &str, operation: Value) -> Value { fn config(root: &std::path::Path) -> private_task::ExecutionConfig { private_task::ExecutionConfig { + native_backend: crate::compute::native_cpu::Options::default(), runtime_root: root.join("runtime"), model_root: root.join("model"), work_parent: root.join("work"), diff --git a/crates/volparossa/src/compute/private_serve/tests/native_session.rs b/crates/volparossa/src/compute/private_serve/tests/native_session.rs new file mode 100644 index 000000000..b0d135404 --- /dev/null +++ b/crates/volparossa/src/compute/private_serve/tests/native_session.rs @@ -0,0 +1,558 @@ +//! Inert connection/ownership futures only. No model, worker or sandbox is run. +use super::*; + +use std::time::Duration; + +use sha2::{Digest as _, Sha256}; + +use super::super::super::supervise::native_session::{Command, Event}; + +fn native_config(root: &std::path::Path) -> private_task::ExecutionConfig { + let mut config = output_constraint::native_config(root, true); + let manifest = config + .native_backend + .native_backend_root + .as_ref() + .unwrap() + .join("backend.json"); + let mut value: Value = serde_json::from_slice(&fs::read(&manifest).unwrap()).unwrap(); + value["version"] = 3.into(); + value["native_sessions"] = json!(["connection_bound_v1"]); + let raw = serde_json::to_vec(&value).unwrap(); + fs::write(manifest, &raw).unwrap(); + config.native_backend.native_backend_sha256 = Some(hex::encode(Sha256::digest(raw))); + config +} + +fn cleanup() -> Value { + json!({"version":1,"child_reaped":true,"private_state_removed":true}) +} + +fn answer() -> Value { + json!({"version":2,"answer":"inert wire fixture","cleanup":{ + "version":2,"request_files_removed":true,"session_closed":false, + "session_private_state_retained":true}}) +} + +fn conversation(instructions: &str) -> Value { + json!({"type":"submit_conversation","conversation":{ + "version":1,"visibility":"private_local","instructions":instructions, + "history":[{"type":"message","role":"user","text":"Hello"}], + "tools":[],"generation_policy":"greedy_v1"}}) +} + +fn fake_session( + id: String, + _config: Arc, + slot: ExecutionSlot, + handshakes: Handshakes, +) -> NativeSession { + fake_session_mode(id, slot, handshakes, false) +} + +fn unconfirmed_session( + id: String, + _config: Arc, + slot: ExecutionSlot, + handshakes: Handshakes, +) -> NativeSession { + fake_session_mode(id, slot, handshakes, true) +} + +fn idle_session( + id: String, + _config: Arc, + slot: ExecutionSlot, + handshakes: Handshakes, +) -> NativeSession { + NativeSession::spawn( + id, + slot, + handshakes, + |_commands, events, _activity| async move { + events.send(Event::Opened).await.unwrap(); + Ok(cleanup()) + }, + ) +} + +fn unknown_failure_session( + id: String, + _config: Arc, + slot: ExecutionSlot, + handshakes: Handshakes, +) -> NativeSession { + NativeSession::spawn( + id, + slot, + handshakes, + |_commands, events, _activity| async move { + events.send(Event::Opened).await.unwrap(); + Err(anyhow::anyhow!("PRIVATE-CANARY-unknown-cleanup")) + }, + ) +} + +fn fake_session_mode( + id: String, + slot: ExecutionSlot, + handshakes: Handshakes, + unconfirmed: bool, +) -> NativeSession { + NativeSession::spawn( + id, + slot, + handshakes, + move |mut commands, events, mut activity| async move { + events.send(Event::Opened).await.unwrap(); + loop { + tokio::select! { + _ = activity.changed() => break, + command = commands.recv() => { + match command { + Some(Command::Submit { id, input }) => { + let value: Value = serde_json::from_slice(&input).unwrap(); + if value["instructions"] != "hold" { + let mut result = answer(); + if value["instructions"] == "wrong cleanup" { + result["answer"] = "PRIVATE-CANARY-never-export".into(); + result["cleanup"] = cleanup(); + } + events.send(Event::Result { id, value: result }).await.unwrap(); + } + } + Some(Command::Close) | None => break, + } + } + } + } + if unconfirmed { + Err(private_task::CleanupUnconfirmed.into()) + } else { + Ok(cleanup()) + } + }, + ) +} + +async fn receive(client: &mut UnixStream) -> Value { + tokio::time::timeout( + Duration::from_secs(3), + wire::read(client, wire::MAX_RESPONSE_BYTES), + ) + .await + .unwrap() + .unwrap() + .unwrap() +} + +async fn send(client: &mut UnixStream, id: &str, operation: Value) { + wire::write(client, &request(id, operation)).await.unwrap(); +} + +async fn negotiate(client: &mut UnixStream) { + send( + client, + FIRST, + json!({"type":"conversation_capabilities", + "generation_policy_version":1,"execution_error_version":1, + "output_constraint_version":1,"native_session_version":1}), + ) + .await; + let reply = receive(client).await; + assert_eq!(reply["capabilities"]["native_session_version"], 1); + assert_eq!( + reply["capabilities"]["native_session"], + json!({"version":1,"max_task_seconds":2400,"max_idle_seconds":300,"max_requests":128}) + ); +} + +#[test] +fn native_session_version_and_operations_reject_unknown_null_duplicate_and_free_fields() { + for version in [ + json!(1), + json!(0), + json!(2), + Value::Null, + json!(true), + json!("1"), + ] { + let value = request( + FIRST, + json!({"type":"conversation_capabilities","native_session_version":version}), + ); + let accepted = serde_json::from_value::(value).is_ok_and(|request| { + request + .validate_profile(ModelProfile::Qwen4bInstruct2507) + .is_ok() + }); + assert_eq!(accepted, version == json!(1)); + } + let duplicate = format!( + r#"{{"version":1,"id":"{FIRST}","operation":{{"type":"conversation_capabilities","native_session_version":1,"native_session_version":1}}}}"# + ); + assert!(serde_json::from_str::(&duplicate).is_err()); + for operation in ["open_native_session", "close_native_session"] { + assert!( + serde_json::from_value::(request(FIRST, json!({"type":operation}))) + .is_ok() + ); + for key in ["session_id", "owner", "path", "max_seconds", "model"] { + let mut value = json!({"type":operation}); + value[key] = "PRIVATE-CANARY".into(); + assert!(serde_json::from_value::(request(FIRST, value)).is_err()); + } + } +} + +#[test] +fn session_capability_requires_opt_in_and_exact_owner_manifest_and_legacy_caps_stay_exact() { + let root = tempfile::tempdir().unwrap(); + let mut config = native_config(root.path()); + let gate = Semaphore::new(1); + let mut handshake = Handshakes::default(); + let original = conversation_capabilities(&config, &gate, &handshake); + assert!(original.get("native_session").is_none()); + handshake.native_session = Some(1); + let mut extended = conversation_capabilities(&config, &gate, &handshake); + assert_eq!( + extended + .as_object_mut() + .unwrap() + .remove("native_session_version"), + Some(json!(1)) + ); + assert_eq!( + extended.as_object_mut().unwrap().remove("native_session"), + Some( + json!({"version":1,"max_task_seconds":2400,"max_idle_seconds":300,"max_requests":128}) + ) + ); + assert_eq!(extended, original); + config.native_backend.native_backend_sha256 = Some("0".repeat(64)); + assert_eq!( + conversation_capabilities(&config, &gate, &handshake)["native_session"], + Value::Null + ); +} + +#[tokio::test] +async fn same_connection_two_turns_retain_slot_until_explicit_session_close_and_eof() { + let root = tempfile::tempdir().unwrap(); + let config = Arc::new(native_config(root.path())); + let gate = Arc::new(Semaphore::new(1)); + let (server, mut client) = UnixStream::pair().unwrap(); + let (_stop, shutdown) = watch::channel(false); + let serving = tokio::spawn(connection_with_native( + server, + config, + gate.clone(), + shutdown, + fake_session, + )); + negotiate(&mut client).await; + send(&mut client, SECOND, json!({"type":"open_native_session"})).await; + assert_eq!( + receive(&mut client).await, + wire::response(SECOND, "native_session_opened") + ); + assert_eq!(gate.available_permits(), 0); + for id in [THIRD, "04040404040404040404040404040404"] { + send(&mut client, id, conversation("Reply")).await; + assert_eq!(receive(&mut client).await, wire::response(id, "admitted")); + let reply = receive(&mut client).await; + assert_eq!(reply["result"], answer()); + assert!(reply["result"]["cleanup"].get("child_reaped").is_none()); + assert_eq!(gate.available_permits(), 0); + } + let close = "05050505050505050505050505050505"; + send(&mut client, close, json!({"type":"close_native_session"})).await; + assert_eq!( + receive(&mut client).await, + json!({"version":1,"id":close,"event":"native_session_closed","cleanup":cleanup()}) + ); + assert!( + wire::read::(&mut client, wire::MAX_RESPONSE_BYTES) + .await + .unwrap() + .is_none() + ); + serving.await.unwrap(); + assert_eq!(gate.available_permits(), 1); + assert!(!gate.is_closed()); +} + +#[tokio::test] +async fn close_during_turn_cancels_before_closed_and_unconfirmed_cleanup_quarantines() { + for unconfirmed in [false, true] { + let root = tempfile::tempdir().unwrap(); + let config = Arc::new(native_config(root.path())); + let gate = Arc::new(Semaphore::new(1)); + let (server, mut client) = UnixStream::pair().unwrap(); + let (_stop, shutdown) = watch::channel(false); + let start: NativeSessionStart = if unconfirmed { + unconfirmed_session + } else { + fake_session + }; + let serving = tokio::spawn(connection_with_native( + server, + config, + gate.clone(), + shutdown, + start, + )); + negotiate(&mut client).await; + send(&mut client, SECOND, json!({"type":"open_native_session"})).await; + assert_eq!(receive(&mut client).await["event"], "native_session_opened"); + send(&mut client, THIRD, conversation("hold")).await; + assert_eq!(receive(&mut client).await["event"], "admitted"); + let close = "04040404040404040404040404040404"; + send(&mut client, close, json!({"type":"close_native_session"})).await; + let reply = receive(&mut client).await; + assert_eq!(reply["id"], THIRD); + assert_eq!( + reply["code"], + if unconfirmed { + "cleanup_unconfirmed" + } else { + "cancelled" + } + ); + assert_eq!( + reply["session_cleanup"], + if unconfirmed { Value::Null } else { cleanup() } + ); + if !unconfirmed { + assert_eq!( + receive(&mut client).await, + json!({"version":1,"id":close,"event":"native_session_closed","cleanup":cleanup()}) + ); + } + serving.await.unwrap(); + assert_eq!(gate.is_closed(), unconfirmed); + } +} + +#[tokio::test] +async fn eof_shutdown_and_invalid_turn_cleanup_wait_for_session_and_never_export_raw_output() { + for mode in ["eof", "shutdown", "invalid"] { + let root = tempfile::tempdir().unwrap(); + let config = Arc::new(native_config(root.path())); + let gate = Arc::new(Semaphore::new(1)); + let (server, mut client) = UnixStream::pair().unwrap(); + let (stop, shutdown) = watch::channel(false); + let serving = tokio::spawn(connection_with_native( + server, + config, + gate.clone(), + shutdown, + fake_session, + )); + negotiate(&mut client).await; + send(&mut client, SECOND, json!({"type":"open_native_session"})).await; + receive(&mut client).await; + let instruction = if mode == "invalid" { + "wrong cleanup" + } else { + "hold" + }; + send(&mut client, THIRD, conversation(instruction)).await; + assert_eq!(receive(&mut client).await["event"], "admitted"); + if mode == "invalid" { + let reply = receive(&mut client).await; + assert_eq!(reply["code"], "execution_failed"); + assert_eq!(reply["session_cleanup"], cleanup()); + assert!(!reply.to_string().contains("PRIVATE-CANARY")); + } else if mode == "shutdown" { + stop.send(true).unwrap(); + } else { + drop(client); + } + tokio::time::timeout(Duration::from_secs(3), serving) + .await + .unwrap() + .unwrap(); + assert_eq!(gate.available_permits(), 1); + assert!(!gate.is_closed()); + } +} + +#[tokio::test] +async fn session_slot_is_connection_exclusive_and_open_requires_its_own_handshake() { + let root = tempfile::tempdir().unwrap(); + let config = Arc::new(native_config(root.path())); + let gate = Arc::new(Semaphore::new(1)); + let (first_server, mut first) = UnixStream::pair().unwrap(); + let (second_server, mut second) = UnixStream::pair().unwrap(); + let (stop, shutdown) = watch::channel(false); + let owner = tokio::spawn(connection_with_native( + first_server, + config.clone(), + gate.clone(), + shutdown.clone(), + fake_session, + )); + let other = tokio::spawn(connection_with_native( + second_server, + config, + gate.clone(), + shutdown, + fake_session, + )); + negotiate(&mut first).await; + send(&mut first, SECOND, json!({"type":"open_native_session"})).await; + receive(&mut first).await; + send(&mut second, THIRD, json!({"type":"open_native_session"})).await; + assert_eq!( + receive(&mut second).await, + wire::error(Some(THIRD), "handshake_required") + ); + negotiate(&mut second).await; + send(&mut second, SECOND, json!({"type":"open_native_session"})).await; + assert_eq!( + receive(&mut second).await, + wire::error(Some(SECOND), "busy") + ); + assert_eq!(gate.available_permits(), 0); + stop.send(true).unwrap(); + owner.await.unwrap(); + other.await.unwrap(); + assert_eq!(gate.available_permits(), 1); +} + +#[tokio::test] +async fn automatic_idle_close_uses_original_open_id_and_requires_joined_cleanup() { + let root = tempfile::tempdir().unwrap(); + let config = Arc::new(native_config(root.path())); + let gate = Arc::new(Semaphore::new(1)); + let (server, mut client) = UnixStream::pair().unwrap(); + let (_stop, shutdown) = watch::channel(false); + let serving = tokio::spawn(connection_with_native( + server, + config, + gate.clone(), + shutdown, + idle_session, + )); + negotiate(&mut client).await; + send(&mut client, SECOND, json!({"type":"open_native_session"})).await; + assert_eq!( + receive(&mut client).await, + wire::response(SECOND, "native_session_opened") + ); + assert_eq!( + receive(&mut client).await, + json!({"version":1,"id":SECOND,"event":"native_session_closed","cleanup":cleanup()}) + ); + serving.await.unwrap(); + assert!(!gate.is_closed()); +} + +#[tokio::test] +async fn turn_count_and_negotiated_constraint_are_fixed_for_the_owner_session() { + let gate = Arc::new(Semaphore::new(1)); + let slot = ExecutionSlot::admit(&gate).unwrap(); + let mut session = NativeSession::spawn( + FIRST.into(), + slot, + Handshakes::default(), + |_commands, _events, mut activity| async move { + activity.changed().await.unwrap(); + Ok(cleanup()) + }, + ); + session.opened = true; + session.requests = 128; + let query = serde_json::from_value(request(SECOND, conversation("Reply"))).unwrap(); + assert_eq!( + session.request(query), + Some(wire::error(Some(SECOND), "invalid_request")) + ); + assert!(session.pending.is_none()); + session.requests = 0; + let constrained = serde_json::from_value(request( + THIRD, + json!({"type":"submit_conversation","conversation":{ + "version":1,"visibility":"private_local","instructions":"Reply", + "history":[{"type":"message","role":"user","text":"Hello"}], + "tools":[{"type":"custom","name":"read","description":"Read"}], + "generation_policy":"greedy_v1","output_constraint":"qwen_tagged_tool_json_v1"}}), + )) + .unwrap(); + assert_eq!( + session.request(constrained), + Some(wire::error(Some(THIRD), "invalid_request")) + ); + session.cancel(); + session.execution.take().unwrap().await.unwrap().unwrap(); + assert_eq!(gate.available_permits(), 1); +} + +#[tokio::test] +async fn ordinary_cancel_closes_the_session_only_after_confirmed_join() { + let root = tempfile::tempdir().unwrap(); + let config = Arc::new(native_config(root.path())); + let gate = Arc::new(Semaphore::new(1)); + let (server, mut client) = UnixStream::pair().unwrap(); + let (_stop, shutdown) = watch::channel(false); + let serving = tokio::spawn(connection_with_native( + server, + config, + gate.clone(), + shutdown, + fake_session, + )); + negotiate(&mut client).await; + send(&mut client, SECOND, json!({"type":"open_native_session"})).await; + receive(&mut client).await; + send(&mut client, THIRD, conversation("hold")).await; + assert_eq!(receive(&mut client).await["event"], "admitted"); + let cancel = "04040404040404040404040404040404"; + send( + &mut client, + cancel, + json!({"type":"cancel","task_id":THIRD}), + ) + .await; + assert_eq!( + receive(&mut client).await, + json!({"version":1,"id":cancel,"event":"cancel_requested","task_id":THIRD}) + ); + assert_eq!( + receive(&mut client).await, + json!({"version":1,"id":THIRD,"event":"error","code":"cancelled","session_cleanup":cleanup()}) + ); + serving.await.unwrap(); + assert_eq!(gate.available_permits(), 1); + assert!(!gate.is_closed()); +} + +#[tokio::test] +async fn opaque_engine_failure_without_cleanup_facts_quarantines_before_slot_release() { + let root = tempfile::tempdir().unwrap(); + let config = Arc::new(native_config(root.path())); + let gate = Arc::new(Semaphore::new(1)); + let (server, mut client) = UnixStream::pair().unwrap(); + let (_stop, shutdown) = watch::channel(false); + let serving = tokio::spawn(connection_with_native( + server, + config, + gate.clone(), + shutdown, + unknown_failure_session, + )); + negotiate(&mut client).await; + send(&mut client, SECOND, json!({"type":"open_native_session"})).await; + assert_eq!(receive(&mut client).await["event"], "native_session_opened"); + let reply = receive(&mut client).await; + assert_eq!( + reply, + json!({"version":1,"id":SECOND,"event":"error", + "code":"cleanup_unconfirmed","session_cleanup":null}) + ); + assert!(!reply.to_string().contains("PRIVATE-CANARY")); + assert!(gate.is_closed()); + assert!(ExecutionSlot::admit(&gate).is_none()); + serving.await.unwrap(); +} diff --git a/crates/volparossa/src/compute/private_serve/tests/output_constraint.rs b/crates/volparossa/src/compute/private_serve/tests/output_constraint.rs new file mode 100644 index 000000000..7559b7f0c --- /dev/null +++ b/crates/volparossa/src/compute/private_serve/tests/output_constraint.rs @@ -0,0 +1,163 @@ +//! Inert owner-manifest and IPC negotiation only; no worker or model is started. +use super::*; + +use sha2::{Digest as _, Sha256}; + +const CONSTRAINT: &str = "qwen_tagged_tool_json_v1"; + +pub(super) fn native_config( + root: &std::path::Path, + extended: bool, +) -> private_task::ExecutionConfig { + let mut config = config(root); + config.model_profile = ModelProfile::Qwen4bInstruct2507; + let backend = root.join("backend"); + fs::create_dir(&backend).unwrap(); + fs::set_permissions(&backend, fs::Permissions::from_mode(0o700)).unwrap(); + for name in ["libvolparossa_llama_cpu.so", "model.gguf"] { + let path = backend.join(name); + fs::write(&path, b"x").unwrap(); + fs::set_permissions(path, fs::Permissions::from_mode(0o600)).unwrap(); + } + let mut manifest = json!({"version":1,"kind":"llama_cpp_bf16_v1","abi_version":1, + "source_commit":"7fe450e19305b828c199d602c23a8337aaa1f03b", + "model_profile":"qwen3-4b-instruct-2507-v1", + "source_weights_sha256":"79f6bbc34572c0063d12022f0f93074d90bbcd5dfd82134423bf892f7f8df3cf", + "source_weights_bytes":8_044_982_000_u64, + "library":{"path":"libvolparossa_llama_cpu.so","sha256":"a".repeat(64),"bytes":1}, + "gguf":{"path":"model.gguf","sha256":"b".repeat(64),"bytes":1}, + "verification":{"tensor_count":398,"tensor_values_equal":true,"tokenizer_equal":true, + "chat_template_sha256":"c".repeat(64)},"build_manifest_sha256":"d".repeat(64)}); + if extended { + manifest["version"] = 2.into(); + manifest["output_constraints"] = json!([CONSTRAINT]); + } + let raw = serde_json::to_vec(&manifest).unwrap(); + fs::write(backend.join("backend.json"), &raw).unwrap(); + config.native_backend.native_backend_root = Some(backend); + config.native_backend.native_backend_sha256 = Some(hex::encode(Sha256::digest(raw))); + config +} + +fn conversation() -> Value { + json!({"version":1,"visibility":"private_local","instructions":"Reply.", + "history":[{"type":"message","role":"user","text":"Read."}], + "tools":[{"type":"custom","name":"tool","description":"Read."}], + "generation_policy":"greedy_v1","output_constraint":CONSTRAINT}) +} + +#[test] +fn constraint_negotiation_rejects_invalid_null_and_duplicate_versions() { + for version in [ + json!(1), + json!(0), + json!(2), + Value::Null, + json!(true), + json!("1"), + ] { + let request = request( + FIRST, + json!({"type":"conversation_capabilities","output_constraint_version":version}), + ); + let valid = serde_json::from_value::(request).is_ok_and(|request| { + request + .validate_profile(ModelProfile::Qwen4bInstruct2507) + .is_ok() + }); + assert_eq!(valid, version == json!(1)); + } + let raw = format!( + r#"{{"version":1,"id":"{FIRST}","operation":{{"type":"conversation_capabilities","output_constraint_version":1,"output_constraint_version":1}}}}"# + ); + assert!(serde_json::from_str::(&raw).is_err()); +} + +#[test] +fn capability_is_opt_in_and_requires_verified_owner_manifest_support() { + for supported in [false, true] { + let root = tempfile::tempdir().unwrap(); + let config = native_config(root.path(), supported); + let gate = Semaphore::new(1); + let mut handshakes = Handshakes::default(); + let original = conversation_capabilities(&config, &gate, &handshakes); + assert!(original.get("output_constraint_version").is_none()); + assert!(original.get("output_constraints").is_none()); + handshakes.output_constraint = Some(1); + let mut caps = conversation_capabilities(&config, &gate, &handshakes); + assert_eq!( + caps.as_object_mut() + .unwrap() + .remove("output_constraint_version"), + Some(json!(1)) + ); + assert_eq!( + caps.as_object_mut().unwrap().remove("output_constraints"), + Some(if supported { + json!([CONSTRAINT]) + } else { + json!([]) + }) + ); + assert_eq!(caps, original); + let mut changed = config; + changed.native_backend.native_backend_sha256 = Some("0".repeat(64)); + assert_eq!( + conversation_capabilities(&changed, &gate, &handshakes)["output_constraints"], + json!([]) + ); + changed.native_backend = super::super::super::native_cpu::Options::default(); + assert_eq!( + conversation_capabilities(&changed, &gate, &handshakes)["output_constraints"], + json!([]) + ); + } +} + +#[test] +fn constrained_admission_requires_current_connection_handshake_and_manifest() { + for supported in [false, true] { + let root = tempfile::tempdir().unwrap(); + let config = Arc::new(native_config(root.path(), supported)); + let gate = Arc::new(Semaphore::new(1)); + // Occupy the only slot: a correctly admitted shape returns busy, never starts a worker. + let _occupied = gate.try_acquire().unwrap(); + let mut active = None; + let mut handshakes = Handshakes::default(); + for selected in [false, true, false] { + let mut operation = + json!({"type":"conversation_capabilities","generation_policy_version":1}); + if selected { + operation["output_constraint_version"] = 1.into(); + } + let query = serde_json::from_value(request(FIRST, operation)).unwrap(); + respond(query, &config, &gate, &mut active, &mut handshakes); + assert_eq!(handshakes.output_constraint, selected.then_some(1)); + let submit = serde_json::from_value(request( + SECOND, + json!({"type":"submit_conversation","conversation":conversation()}), + )) + .unwrap(); + let result = respond(submit, &config, &gate, &mut active, &mut handshakes); + let expected = if !selected { + "handshake_required" + } else if !supported { + "invalid_request" + } else { + "busy" + }; + assert_eq!(result, wire::error(Some(SECOND), expected)); + assert!(active.is_none()); + } + let mut other_connection = Handshakes::default(); + let submit = serde_json::from_value(request( + THIRD, + json!({"type":"submit_conversation","conversation":conversation()}), + )) + .unwrap(); + assert_eq!( + respond(submit, &config, &gate, &mut active, &mut other_connection), + wire::error(Some(THIRD), "handshake_required") + ); + } +} diff --git a/crates/volparossa/src/compute/private_serve/tests/task_diagnostic.rs b/crates/volparossa/src/compute/private_serve/tests/task_diagnostic.rs new file mode 100644 index 000000000..6d5d54320 --- /dev/null +++ b/crates/volparossa/src/compute/private_serve/tests/task_diagnostic.rs @@ -0,0 +1,165 @@ +//! Closed service-boundary observations, not actual model or sandbox evidence. + +use super::*; + +const CHILD: &str = "VOLPAROSSA_TASK_DIAGNOSTIC_CHILD"; +const INPUT: &[u8] = br#"{ + "version":1,"visibility":"private_local","question":"Hello?","context":"PRIVATE_TASK_CANARY" +}"#; + +fn result_binding_failure() -> anyhow::Error { + // The real validator rejects this synthetic worker report. No model runs. + private_task::validate_mode_report( + &json!({"status":"ok","outputs":[{"text":"PRIVATE_TASK_CANARY"}]}), + INPUT, + ModelProfile::Smol360, + super::super::super::Mode::PrivateInfer, + ) + .unwrap_err() +} + +const PARSER_CHECK: &str = r" +import json, runpy, sys +from pathlib import Path +fixture = runpy.run_path(sys.argv[1]) +value = fixture['service_diagnostic'](Path(sys.argv[2])) +codes = ['compute_private_budget', 'compute_private_result_scope', 'unclassified'] if sys.argv[3] == '1' else [] +assert value == dict(version=1, truncated=False, unrecognized_record=False, events=[dict( + version=1, phase='task', detail=dict(code=code, io_kind='none', exit_code=None, + signal=None, stderr_class=None)) for code in codes]) +assert 'PRIVATE_TASK_CANARY' not in json.dumps(value) +"; + +#[test] +fn service_task_failures_cover_pre_supervisor_and_result_binding_only_when_opted_in() { + if std::env::var_os(CHILD).is_some() { + let subscriber = tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::try_from_default_env().unwrap()) + .with_ansi(false) + .with_target(false) + .finish(); + let _subscriber = tracing::subscriber::set_default(subscriber); + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(async { + let root = tempfile::tempdir().unwrap(); + let mut config = config(root.path()); + config.threads = 0; + let gate = Arc::new(Semaphore::new(1)); + let slot = ExecutionSlot::admit(&gate).unwrap(); + let mut task = Active::start(FIRST.into(), Arc::new(config), INPUT.to_vec(), slot); + let result = task.reap().await; + assert_eq!( + result.as_ref().unwrap_err().to_string(), + "compute_private_budget" + ); + assert_eq!( + terminal_response(&task, Ok(result), &gate), + wire::error(Some(FIRST), "execution_failed") + ); + assert_eq!(gate.available_permits(), 1); + assert!(!root.path().join("work").exists()); + + for error in [ + result_binding_failure(), + anyhow::anyhow!("PRIVATE_TASK_CANARY /private/task"), + ] { + let mut slot = ExecutionSlot::admit(&gate).unwrap(); + let result = finish_execution(&mut slot, Err(error)); + assert!(slot.cleanup_confirmed); + drop(slot); + assert_eq!( + terminal_response(&task, Ok(result), &gate), + wire::error(Some(FIRST), "execution_failed") + ); + } + }); + return; + } + for enabled in [false, true] { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "compute::private_serve::tests::task_diagnostic::service_task_failures_cover_pre_supervisor_and_result_binding_only_when_opted_in", + "--nocapture", + "--test-threads=1", + ]) + .env(CHILD, "1") + .env( + "RUST_LOG", + if enabled { + "off,volparossa::compute::private_diagnostic=debug" + } else { + "off" + }, + ) + .output() + .unwrap(); + assert!(output.status.success()); + assert!(output.stderr.is_empty()); + let text = std::str::from_utf8(&output.stdout).unwrap(); + assert!(!text.contains("PRIVATE_TASK_CANARY") && !text.contains("/private/task")); + let mut log = tempfile::NamedTempFile::new().unwrap(); + std::io::Write::write_all(&mut log, &output.stdout).unwrap(); + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/integration/agent-private-conversation.py"); + let parsed = std::process::Command::new("python3") + .args(["-B", "-c", PARSER_CHECK]) + .arg(fixture) + .arg(log.path()) + .arg(if enabled { "1" } else { "0" }) + .output() + .unwrap(); + assert!(parsed.status.success()); + assert!(parsed.stdout.is_empty() && parsed.stderr.is_empty()); + } +} + +#[test] +fn task_diagnostic_cannot_override_cleanup_cancel_or_success() { + let (activity, _signal) = watch::channel(true); + let mut task = Active { + id: FIRST.into(), + activity, + execution: None, + cancelled: false, + budget_error: true, + }; + for cancelled in [false, true] { + task.cancelled = cancelled; + let gate = Arc::new(Semaphore::new(1)); + let mut slot = ExecutionSlot::admit(&gate).unwrap(); + let result = finish_execution(&mut slot, Err(result_binding_failure())); + drop(slot); + let expected = if cancelled { + "cancelled" + } else { + "execution_failed" + }; + assert_eq!( + terminal_response(&task, Ok(result), &gate), + wire::error(Some(FIRST), expected) + ); + let mut slot = ExecutionSlot::admit(&gate).unwrap(); + let result = finish_execution(&mut slot, Err(private_task::CleanupUnconfirmed.into())); + assert!(!slot.cleanup_confirmed); + drop(slot); + assert!(gate.is_closed()); + assert_eq!( + terminal_response(&task, Ok(result), &gate), + wire::error(Some(FIRST), "cleanup_unconfirmed") + ); + } + task.cancelled = false; + let gate = Arc::new(Semaphore::new(1)); + let mut slot = ExecutionSlot::admit(&gate).unwrap(); + let answer = json!({"synthetic":"unchanged"}); + let result = finish_execution(&mut slot, Ok(answer.clone())); + drop(slot); + assert_eq!( + terminal_response(&task, Ok(result), &gate)["result"], + answer + ); +} diff --git a/crates/volparossa/src/compute/private_serve/wire.rs b/crates/volparossa/src/compute/private_serve/wire.rs index 618a5a682..6c9a665f0 100644 --- a/crates/volparossa/src/compute/private_serve/wire.rs +++ b/crates/volparossa/src/compute/private_serve/wire.rs @@ -31,7 +31,13 @@ pub(super) enum Operation { generation_policy_version: Option, #[serde(default, deserialize_with = "present_version")] execution_error_version: Option, + #[serde(default, deserialize_with = "present_version")] + output_constraint_version: Option, + #[serde(default, deserialize_with = "present_version")] + native_session_version: Option, }, + OpenNativeSession {}, + CloseNativeSession {}, SubmitConversation { conversation: super::super::private_conversation::Input, }, @@ -75,6 +81,8 @@ impl Request { if let Operation::ConversationCapabilities { generation_policy_version, execution_error_version, + output_constraint_version, + native_session_version, } = &self.operation { ensure!( @@ -85,6 +93,14 @@ impl Request { execution_error_version.is_none_or(|version| version == 1), "private_ipc_execution_error_version" ); + ensure!( + output_constraint_version.is_none_or(|version| version == 1), + "private_ipc_output_constraint_version" + ); + ensure!( + native_session_version.is_none_or(|version| version == 1), + "private_ipc_native_session_version" + ); } if let Operation::Cancel { task_id } = &self.operation { ensure!(valid_id(task_id), "private_ipc_invalid_request"); diff --git a/crates/volparossa/src/compute/private_task.rs b/crates/volparossa/src/compute/private_task.rs index d19be7702..5f7edbc98 100644 --- a/crates/volparossa/src/compute/private_task.rs +++ b/crates/volparossa/src/compute/private_task.rs @@ -49,6 +49,7 @@ pub(super) struct ExecutionConfig { pub model_root: PathBuf, pub work_parent: PathBuf, pub model_profile: ModelProfile, + pub native_backend: super::native_cpu::Options, pub threads: u16, pub max_seconds: u16, } @@ -60,6 +61,7 @@ impl From<&Options> for ExecutionConfig { model_root: args.model_root.clone(), work_parent: args.work_parent.clone(), model_profile: args.model_profile, + native_backend: super::native_cpu::Options::default(), threads: args.threads, max_seconds: args.max_seconds, } @@ -75,6 +77,13 @@ impl ExecutionConfig { super::private_directory(&self.runtime_root)?; super::private_directory(&self.model_root)?; super::private_directory(&self.work_parent)?; + self.native_backend.validate(self.model_profile)?; + if let Some(root) = &self.native_backend.native_backend_root { + ensure!( + !self.work_parent.starts_with(root) && !root.starts_with(&self.work_parent), + "compute_native_backend_work_overlap" + ); + } Ok(()) } } @@ -181,6 +190,8 @@ impl Staged { } fn from_mode(args: &ExecutionConfig, input: &[u8], mode: Mode) -> Result { + args.native_backend + .validate_input(mode, input, args.model_profile)?; super::private_directory(&args.work_parent)?; let directory = tempfile::Builder::new() .prefix("private-task-") @@ -200,6 +211,7 @@ impl Staged { runtime_root: args.runtime_root.clone(), model_root: args.model_root.clone(), model_profile: args.model_profile, + native_backend: args.native_backend.clone(), adapter_root: None, dataset, output: directory.path().join("output"), @@ -249,6 +261,29 @@ pub(super) fn validate_mode_report( raw: &[u8], profile: ModelProfile, mode: Mode, +) -> Result<()> { + validate_mode_report_lifetime(report, raw, profile, mode, true) +} + +pub(super) fn validate_native_session_report( + report: &Value, + raw: &[u8], + profile: ModelProfile, +) -> Result<()> { + ensure!( + profile == ModelProfile::Qwen4bInstruct2507 + && report["supervisor"]["native_session_version"] == 1, + "compute_native_session_result_scope" + ); + validate_mode_report_lifetime(report, raw, profile, Mode::PrivateConversation, false) +} + +fn validate_mode_report_lifetime( + report: &Value, + raw: &[u8], + profile: ModelProfile, + mode: Mode, + child_reaped: bool, ) -> Result<()> { validate_profile_input(mode, raw, profile)?; let spec = profile.spec(); @@ -259,7 +294,7 @@ pub(super) fn validate_mode_report( && report["private_data_supported"] == true && report["distributed_execution_claimed"] == false && report["private_training_claimed"] == false - && report["supervisor"]["child_reaped"] == true + && report["supervisor"]["child_reaped"] == child_reaped && report["supervisor"]["network_access"] == false && report["model_weights_loaded"] == true && report.get("baseline_evaluation").is_none() diff --git a/crates/volparossa/src/compute/resources.rs b/crates/volparossa/src/compute/resources.rs index 380206f19..c0d854b71 100644 --- a/crates/volparossa/src/compute/resources.rs +++ b/crates/volparossa/src/compute/resources.rs @@ -46,12 +46,26 @@ pub(super) fn admits(profile: ModelProfile, available: Option) -> bool { .is_none_or(|required| available.is_some_and(|bytes| bytes >= required)) } -pub(super) fn admission(profile: ModelProfile) -> Result<()> { - if limits(profile).admission_headroom.is_some() { - ensure!( - admits(profile, super::spare_capacity::memory_headroom()), - "compute_model_memory_headroom" - ); +pub(super) fn admission(profile: ModelProfile, diagnostic: bool) -> Result<()> { + admission_with(profile, diagnostic, super::spare_capacity::memory::sample) +} + +fn admission_with( + profile: ModelProfile, + diagnostic: bool, + sample: impl FnOnce(bool) -> super::spare_capacity::memory::Sample, +) -> Result<()> { + if let Some(required) = limits(profile).admission_headroom { + let observe = diagnostic && super::spare_capacity::memory::enabled(); + let sample = sample(observe); + let admitted = admits(profile, sample.headroom); + if observe { + super::spare_capacity::memory::emit(&serde_json::json!({ + "version":1,"phase":"admission","required_bytes":required, + "admitted":admitted,"sample":sample.snapshot() + })); + } + ensure!(admitted, "compute_model_memory_headroom"); } Ok(()) } @@ -60,6 +74,106 @@ pub(super) fn admission(profile: ModelProfile) -> Result<()> { mod tests { use super::*; + #[derive(Clone)] + struct Log(std::sync::Arc>>); + + impl std::io::Write for Log { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + #[test] + fn admission_observation_requires_both_explicit_native_scope_and_debug_opt_in() { + let profile = ModelProfile::Qwen4bInstruct2507; + for diagnostic in [false, true] { + for enabled in [false, true] { + let log = Log(std::sync::Arc::default()); + let writer = log.clone(); + let subscriber = tracing_subscriber::fmt() + .without_time() + .with_ansi(false) + .with_target(false) + .with_level(false) + .with_max_level(if enabled { + tracing::Level::DEBUG + } else { + tracing::Level::ERROR + }) + .with_writer(move || writer.clone()) + .finish(); + tracing::subscriber::with_default(subscriber, || { + let result = admission_with(profile, diagnostic, |stats| { + assert_eq!(stats, diagnostic && enabled); + super::super::spare_capacity::memory::Sample::for_test(None) + }); + assert_eq!( + result.unwrap_err().to_string(), + "compute_model_memory_headroom" + ); + }); + let raw = log.0.lock().unwrap(); + if diagnostic && enabled { + let text = std::str::from_utf8(&raw).unwrap(); + let record: serde_json::Value = serde_json::from_str( + text.trim() + .strip_prefix("private_memory_diagnostic ") + .unwrap(), + ) + .unwrap(); + assert_eq!(record["version"], 1); + assert_eq!(record["phase"], "admission"); + assert_eq!(record["required_bytes"], 11_274_289_152_u64); + assert_eq!(record["admitted"], false); + assert_eq!(record["sample"]["missing_stage"], "meminfo"); + assert!(raw.len() <= 4096); + } else { + assert!(raw.is_empty()); + } + } + } + } + + #[test] + fn memory_diagnostic_sampling_uses_the_same_unbounded_value_once() { + let profile = ModelProfile::Qwen4bInstruct2507; + let required = limits(profile).admission_headroom.unwrap(); + for diagnostic in [false, true] { + for value in [ + None, + Some(0), + Some(required - 1), + Some(required), + Some(u64::MAX), + ] { + let calls = std::cell::Cell::new(0); + let result = admission_with(profile, diagnostic, |_| { + calls.set(calls.get() + 1); + super::super::spare_capacity::memory::Sample::for_test(value) + }); + assert_eq!(calls.get(), 1); + assert_eq!(result.is_ok(), admits(profile, value)); + if let Err(error) = result { + assert_eq!(error.to_string(), "compute_model_memory_headroom"); + } + } + } + } + + #[test] + fn profiles_without_admission_never_gain_a_memory_sample() { + for profile in [ModelProfile::Default135, ModelProfile::Smol360] { + for diagnostic in [false, true] { + admission_with(profile, diagnostic, |_| panic!("unrequested sample")).unwrap(); + } + } + } + #[test] fn existing_profiles_keep_their_exact_resource_contracts() { for profile in [ModelProfile::Default135, ModelProfile::Smol360] { diff --git a/crates/volparossa/src/compute/sandbox.rs b/crates/volparossa/src/compute/sandbox.rs index c9909f115..c3e75d3f1 100644 --- a/crates/volparossa/src/compute/sandbox.rs +++ b/crates/volparossa/src/compute/sandbox.rs @@ -1,6 +1,6 @@ //! Fixed Debian sandbox, never a remotely supplied executable or shell command. -use std::process::Stdio; +use std::{path::Path, process::Stdio}; use tokio::process::Command; @@ -48,6 +48,12 @@ fn worker_source() -> String { let qwen = serde_json::json!(include_str!( "../../../../workers/volparossa-ml/qwen_conversation.py" )); + let native_cpu = serde_json::json!(include_str!( + "../../../../workers/volparossa-ml/llama_cpu.py" + )); + let native_session = serde_json::json!(include_str!( + "../../../../workers/volparossa-ml/native_session.py" + )); format!( "import sys as _vp_sys, types as _vp_types\n\ _vp_decoder = _vp_types.ModuleType('volparossa_task_graph_decoder')\n\ @@ -61,15 +67,32 @@ fn worker_source() -> String { _vp_sys.modules['volparossa_conversation'] = _vp_conversation\n\ _vp_qwen = _vp_types.ModuleType('volparossa_qwen_conversation')\n\ exec({qwen}, _vp_qwen.__dict__)\n\ - _vp_sys.modules['volparossa_qwen_conversation'] = _vp_qwen\n{}", + _vp_sys.modules['volparossa_qwen_conversation'] = _vp_qwen\n\ + _vp_native_cpu = _vp_types.ModuleType('volparossa_llama_cpu')\n\ + _vp_sys.modules['volparossa_llama_cpu'] = _vp_native_cpu\n\ + exec({native_cpu}, _vp_native_cpu.__dict__)\n\ + _vp_native_session = _vp_types.ModuleType('volparossa_native_session')\n\ + _vp_sys.modules['volparossa_native_session'] = _vp_native_session\n\ + exec({native_session}, _vp_native_session.__dict__)\n{}", include_str!("../../../../workers/volparossa-ml/worker.py") ) } +pub(super) fn command(options: &Options) -> Command { + command_inner(options, None) +} + +pub(super) fn native_session_command(options: &Options, root: &Path) -> Command { + command_inner(options, Some(root)) +} + // Keep the complete fixed sandbox argument vector reviewable in one place. #[allow(clippy::too_many_lines)] -pub(super) fn command(options: &Options) -> Command { +fn command_inner(options: &Options, session_root: Option<&Path>) -> Command { let mut command = Command::new("/usr/bin/bwrap"); + if let Some(root) = &options.native_backend.native_backend_root { + command.arg("--ro-bind").arg(root).arg("/native-backend"); + } if let Some(adapter) = &options.adapter_root { command.arg("--ro-bind").arg(adapter).arg("/adapter"); } @@ -112,16 +135,26 @@ pub(super) fn command(options: &Options) -> Command { .arg("/runtime") .arg("--ro-bind") .arg(&options.model_root) - .arg("/model") - .arg("--ro-bind") - .arg(&options.dataset) - .arg("/dataset.json") - .arg("--bind") - .arg(&options.output) - .arg("/output") + .arg("/model"); + if let Some(root) = session_root { + command.arg("--bind").arg(root).arg("/session"); + } else { + command + .arg("--ro-bind") + .arg(&options.dataset) + .arg("/dataset.json") + .arg("--bind") + .arg(&options.output) + .arg("/output"); + } + command .args([ "--chdir", - "/output", + if session_root.is_some() { + "/session" + } else { + "/output" + }, "--setenv", "LANG", "C.UTF-8", @@ -165,7 +198,8 @@ pub(super) fn command(options: &Options) -> Command { // to use that same window rather than killing a two-thread job early. &format!( "--cpu={}", - u64::from(options.max_seconds) * u64::from(options.threads) + session_root.map_or(u64::from(options.max_seconds), |_| 2400) + * u64::from(options.threads) ), "/usr/bin/nice", "-n", @@ -189,11 +223,50 @@ pub(super) fn command(options: &Options) -> Command { mod tests { use super::{SOURCE_ARGUMENT_BYTES, WORKER_BOOTSTRAP, source_arguments, worker_source}; + #[test] + fn native_backend_has_only_the_explicit_readonly_mount_and_fixed_bundled_code() { + let mut options = super::Options { + mode: crate::compute::Mode::PrivateConversation, + runtime_root: "/owner/runtime".into(), + model_root: "/owner/model".into(), + model_profile: crate::compute::ModelProfile::Qwen4bInstruct2507, + native_backend: crate::compute::native_cpu::Options::default(), + adapter_root: None, + dataset: "/owner/input".into(), + output: "/owner/output".into(), + steps: 1, + threads: 2, + max_seconds: 600, + spare_capacity: true, + execute: true, + }; + let arguments = |options: &super::Options| -> Vec { + super::command(options) + .as_std() + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect() + }; + let legacy = arguments(&options); + assert!(!legacy.iter().any(|arg| arg == "/native-backend")); + options.native_backend.native_backend_root = Some("/owner/native".into()); + options.native_backend.native_backend_sha256 = Some("a".repeat(64)); + let native = arguments(&options); + assert_eq!( + &native[..3], + ["--ro-bind", "/owner/native", "/native-backend"] + ); + assert_eq!(&native[3..], legacy); + assert!(worker_source().contains("ModuleType('volparossa_llama_cpu')")); + assert!(native.iter().any(|arg| arg == "--unshare-net")); + } + #[test] fn kernel_cpu_limit_covers_only_the_authorized_wall_window_and_worker_threads() { for threads in [1, 2] { for seconds in [1, 600] { let options = super::Options { + native_backend: crate::compute::native_cpu::Options::default(), mode: crate::compute::Mode::PrivateConversation, runtime_root: "/runtime-fixture".into(), model_root: "/model-fixture".into(), @@ -240,6 +313,54 @@ mod tests { } } + #[test] + fn native_session_keeps_fixed_sandbox_with_distinct_task_cpu_and_mount_lifetime() { + let options = super::Options { + mode: crate::compute::Mode::PrivateConversation, + runtime_root: "/owner/runtime".into(), + model_root: "/owner/model".into(), + model_profile: crate::compute::ModelProfile::Qwen4bInstruct2507, + native_backend: crate::compute::native_cpu::Options { + native_backend_root: Some("/owner/native".into()), + native_backend_sha256: Some("a".repeat(64)), + }, + adapter_root: None, + dataset: "/owner/session/turn-1/input.json".into(), + output: "/owner/session/turn-1/output".into(), + steps: 1, + threads: 2, + max_seconds: 600, + spare_capacity: true, + execute: true, + }; + let command = + super::native_session_command(&options, std::path::Path::new("/owner/session")); + let arguments: Vec<_> = command.as_std().get_args().collect(); + assert!( + arguments + .windows(3) + .any(|args| args == ["--bind", "/owner/session", "/session"]) + ); + for (source, destination) in [ + ("/owner/model", "/model"), + ("/owner/runtime", "/runtime"), + ("/owner/native", "/native-backend"), + ] { + assert!( + arguments + .windows(3) + .any(|args| args == ["--ro-bind", source, destination]) + ); + } + for flag in ["--unshare-net", "--die-with-parent", "--cpu=4800"] { + assert!(arguments.iter().any(|arg| *arg == flag)); + } + assert!(!arguments.iter().any(|arg| *arg == "/dataset.json")); + let legacy = super::command(&options); + assert!(legacy.as_std().get_args().any(|arg| arg == "--cpu=1200")); + assert!(worker_source().contains("ModuleType('volparossa_native_session')")); + } + #[test] fn fixed_worker_bundles_decoder_without_a_filesystem_import_or_oversized_argument() { let source = worker_source(); diff --git a/crates/volparossa/src/compute/spare_capacity.rs b/crates/volparossa/src/compute/spare_capacity.rs index f7c1a4800..2c793536e 100644 --- a/crates/volparossa/src/compute/spare_capacity.rs +++ b/crates/volparossa/src/compute/spare_capacity.rs @@ -13,6 +13,8 @@ use std::{ use super::device_capacity::{DeviceBudget, DeviceObservation}; use serde::Serialize; +pub(super) mod memory; + const MAX_FILE_BYTES: u64 = 16 * 1024; const MAX_CGROUP_DEPTH: usize = 64; const MIN_MEMORY_BYTES: u64 = 512 * 1024 * 1024; @@ -277,54 +279,16 @@ fn capped_memory(available: u64, limit: &str, current: Option<&str>) -> Option Option { - let host = available_memory(&system_text(Path::new("/proc/meminfo")).ok()?)?; - let relative = unified_path(&system_text(Path::new("/proc/self/cgroup")).ok()?)?; - cgroup_headroom(host, &relative, system_text) + memory::sample(false).headroom } +#[cfg(test)] fn cgroup_headroom( - mut available: u64, + available: u64, relative: &Path, read: impl Fn(&Path) -> Result, ) -> Option { - let root = Path::new("/sys/fs/cgroup"); - let mut path = root.join(relative); - loop { - match read(&path.join("memory.max")) { - Ok(limit) => { - let current = if limit.trim() == "max" { - None - } else { - Some(read(&path.join("memory.current")).ok()?) - }; - available = capped_memory(available, &limit, current.as_deref())?; - } - Err(ErrorKind::NotFound) if path == root => { - // The actual unified hierarchy root has no memory.max. Its - // supported controllers distinguish that case from absent data. - let controllers = read(&path.join("cgroup.controllers")).ok()?; - if !controllers.split_whitespace().any(|name| name == "memory") { - return None; - } - } - Err(ErrorKind::NotFound) => { - // With memory disabled on the parent this child has no own cap; - // continue upward, where an ancestor can still impose one. - let parent = path.parent().filter(|parent| parent.starts_with(root))?; - let controllers = read(&parent.join("cgroup.subtree_control")).ok()?; - if controllers.split_whitespace().any(|name| name == "memory") { - return None; - } - } - Err(_) => return None, - } - if path == root { - return Some(available); - } - if !path.pop() || !path.starts_with(root) { - return None; - } - } + memory::cgroup_sample(available, relative, &read, false).headroom } #[cfg(test)] diff --git a/crates/volparossa/src/compute/spare_capacity/memory.rs b/crates/volparossa/src/compute/spare_capacity/memory.rs new file mode 100644 index 000000000..7989dd80e --- /dev/null +++ b/crates/volparossa/src/compute/spare_capacity/memory.rs @@ -0,0 +1,323 @@ +//! The original admission sample, with opt-in closed observations of its inputs. +//! Reads are sequential, not an atomic snapshot, reservation or reclaim proof. + +use std::{io::ErrorKind, path::Path}; + +use serde_json::{Value, json}; + +const JSON_MAX: u64 = (1 << 53) - 1; +const STAT_KEYS: [&str; 6] = [ + "anon", + "file", + "shmem", + "inactive_file", + "active_file", + "kernel", +]; + +#[derive(Clone, Debug)] +struct Stat { + state: &'static str, + values: [Option; 6], +} + +impl Default for Stat { + fn default() -> Self { + Self { + state: "unobserved", + values: [None; 6], + } + } +} + +impl Stat { + fn observe(path: &Path, read: &impl Fn(&Path) -> Result) -> Self { + let Ok(raw) = read(&path.join("memory.stat")) else { + return Self { + state: "unavailable", + ..Self::default() + }; + }; + let mut result = Self { + state: "observed", + ..Self::default() + }; + for line in raw.lines() { + let mut fields = line.split_whitespace(); + let Some(key) = fields.next() else { continue }; + let Some(index) = STAT_KEYS.iter().position(|candidate| *candidate == key) else { + continue; + }; + let value = fields.next().and_then(super::unsigned); + if result.values[index].is_some() || value.is_none() || fields.next().is_some() { + return Self { + state: "invalid", + ..Self::default() + }; + } + result.values[index] = value; + } + if result.values.iter().any(Option::is_none) { + return Self { + state: "invalid", + ..Self::default() + }; + } + result + } + + fn overflow(&self) -> bool { + self.values.iter().flatten().any(|value| *value > JSON_MAX) + } + + fn snapshot(&self) -> Value { + let overflow = self.overflow(); + let values = if overflow { [None; 6] } else { self.values }; + json!({"state":if overflow { "unrepresentable" } else { self.state }, + "anon_bytes":values[0],"file_bytes":values[1],"shmem_bytes":values[2], + "inactive_file_bytes":values[3],"active_file_bytes":values[4],"kernel_bytes":values[5]}) + } +} + +#[derive(Clone, Debug)] +pub(in crate::compute) struct Sample { + pub(in crate::compute) headroom: Option, + host: Option, + own: Option, + ancestor: Option, + own_current: Option, + own_max: Option, + ancestor_current: Option, + ancestor_max: Option, + missing: &'static str, + io_kind: &'static str, + own_stat: Stat, + ancestor_stat: Stat, +} + +impl Default for Sample { + fn default() -> Self { + Self { + headroom: None, + host: None, + own: None, + ancestor: None, + own_current: None, + own_max: None, + ancestor_current: None, + ancestor_max: None, + missing: "none", + io_kind: "none", + own_stat: Stat::default(), + ancestor_stat: Stat::default(), + } + } +} + +impl Sample { + #[cfg(test)] + pub(in crate::compute) fn for_test(headroom: Option) -> Self { + Self { + headroom, + host: headroom, + missing: if headroom.is_some() { + "none" + } else { + "meminfo" + }, + io_kind: if headroom.is_some() { + "none" + } else { + "invalid_data" + }, + ..Self::default() + } + } + + fn failed(mut self, stage: &'static str, error: ErrorKind) -> Self { + self.headroom = None; + self.missing = stage; + self.io_kind = match error { + ErrorKind::NotFound => "not_found", + ErrorKind::PermissionDenied => "permission", + ErrorKind::InvalidData => "invalid_data", + _ => "other", + }; + self + } + + fn record_cap(&mut self, limit: &str, current: Option<&str>, own: bool) -> bool { + // capped_memory already validated these strings. This records that + // decision's inputs; optional statistics never participate in it. + let maximum = super::unsigned(limit.trim()); + let used = current.and_then(|raw| super::unsigned(raw.trim())); + if let (Some(maximum), Some(used)) = (maximum, used) { + let free = maximum.saturating_sub(used); + if own { + self.own = Some(free); + self.own_current = Some(used); + self.own_max = Some(maximum); + } else if self.ancestor.is_none_or(|previous| free < previous) { + self.ancestor = Some(free); + self.ancestor_current = Some(used); + self.ancestor_max = Some(maximum); + return true; + } + } + false + } + + pub(in crate::compute) fn snapshot(&self) -> Value { + let values = [ + self.headroom, + self.host, + self.own, + self.ancestor, + self.own_current, + self.own_max, + self.ancestor_current, + self.ancestor_max, + ]; + let overflow = values.iter().flatten().any(|value| *value > JSON_MAX) + || self.own_stat.overflow() + || self.ancestor_stat.overflow(); + let values = values.map(|value| value.filter(|number| *number <= JSON_MAX)); + let limiting = self.headroom.map_or("unknown", |minimum| { + let scopes = [ + (self.host, "host"), + (self.own, "self"), + (self.ancestor, "ancestor"), + ]; + let mut matching = scopes + .into_iter() + .filter(|(value, _)| *value == Some(minimum)); + let first = matching.next().map_or("unknown", |(_, scope)| scope); + if matching.next().is_some() { + "multiple" + } else { + first + } + }); + json!({"headroom_bytes":values[0],"host_available_bytes":values[1], + "self_headroom_bytes":values[2],"ancestor_headroom_bytes":values[3], + "self_current_bytes":values[4],"self_max_bytes":values[5], + "ancestor_current_bytes":values[6],"ancestor_max_bytes":values[7], + "limiting_scope":limiting,"missing_stage":self.missing,"io_kind":self.io_kind, + "numeric_overflow":overflow,"self_stat":self.own_stat.snapshot(), + "ancestor_stat":self.ancestor_stat.snapshot()}) + } +} + +pub(in crate::compute) fn enabled() -> bool { + tracing::enabled!(target: "volparossa::compute::private_diagnostic", tracing::Level::DEBUG) +} + +pub(in crate::compute) fn emit(record: &Value) { + tracing::debug!(target: "volparossa::compute::private_diagnostic", + "private_memory_diagnostic {record}"); +} + +pub(in crate::compute) fn sample(stats: bool) -> Sample { + sample_with(stats, &super::system_text) +} + +fn sample_with(stats: bool, read: &impl Fn(&Path) -> Result) -> Sample { + let host = match read(Path::new("/proc/meminfo")) { + Ok(raw) => match super::available_memory(&raw) { + Some(value) => value, + None => return Sample::default().failed("meminfo", ErrorKind::InvalidData), + }, + Err(error) => return Sample::default().failed("meminfo", error), + }; + let partial = Sample { + host: Some(host), + ..Sample::default() + }; + let relative = match read(Path::new("/proc/self/cgroup")) { + Ok(raw) => match super::unified_path(&raw) { + Some(value) => value, + None => return partial.failed("cgroup_path", ErrorKind::InvalidData), + }, + Err(error) => return partial.failed("cgroup_path", error), + }; + cgroup_sample(host, &relative, read, stats) +} + +pub(super) fn cgroup_sample( + host: u64, + relative: &Path, + read: &impl Fn(&Path) -> Result, + stats: bool, +) -> Sample { + let root = Path::new("/sys/fs/cgroup"); + let own_path = root.join(relative); + let mut path = own_path.clone(); + let mut ancestor_path = None; + let mut available = host; + let mut observed = Sample { + host: Some(host), + ..Sample::default() + }; + loop { + match read(&path.join("memory.max")) { + Ok(limit) => { + let current = if limit.trim() == "max" { + None + } else { + match read(&path.join("memory.current")) { + Ok(raw) => Some(raw), + Err(error) => return observed.failed("current", error), + } + }; + let Some(next) = super::capped_memory(available, &limit, current.as_deref()) else { + let stage = if super::unsigned(limit.trim()).is_none() { + "max" + } else { + "current" + }; + return observed.failed(stage, ErrorKind::InvalidData); + }; + available = next; + if observed.record_cap(&limit, current.as_deref(), path == own_path) { + ancestor_path = Some(path.clone()); + } + } + Err(ErrorKind::NotFound) => { + let control = if path == root { + path.join("cgroup.controllers") + } else { + let Some(parent) = path.parent().filter(|parent| parent.starts_with(root)) + else { + return observed.failed("cgroup_path", ErrorKind::InvalidData); + }; + parent.join("cgroup.subtree_control") + }; + let controllers = match read(&control) { + Ok(raw) => raw, + Err(error) => return observed.failed("controller", error), + }; + let memory = controllers.split_whitespace().any(|name| name == "memory"); + if memory != (path == root) { + return observed.failed("controller", ErrorKind::InvalidData); + } + } + Err(error) => return observed.failed("max", error), + } + if path == root { + observed.headroom = Some(available); + if stats { + observed.own_stat = Stat::observe(&own_path, read); + if let Some(ancestor) = ancestor_path { + observed.ancestor_stat = Stat::observe(&ancestor, read); + } + } + return observed; + } + if !path.pop() || !path.starts_with(root) { + return observed.failed("cgroup_path", ErrorKind::InvalidData); + } + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/volparossa/src/compute/spare_capacity/memory/tests.rs b/crates/volparossa/src/compute/spare_capacity/memory/tests.rs new file mode 100644 index 000000000..cfdcb13b8 --- /dev/null +++ b/crates/volparossa/src/compute/spare_capacity/memory/tests.rs @@ -0,0 +1,338 @@ +//! Injected kernel text only: no host pressure, cgroup writes or model execution. + +use std::{cell::RefCell, collections::BTreeMap, path::PathBuf}; + +use super::*; + +type Files = BTreeMap>; + +const ROOT: &str = "/sys/fs/cgroup"; +const GROUP: &str = "PRIVATE_CGROUP_CANARY/child"; +const STAT: &str = "anon 40\nfile 50\nshmem 3\ninactive_file 20\nactive_file 30\nkernel 10\n"; + +fn files() -> Files { + BTreeMap::from([ + ( + PathBuf::from("/proc/meminfo"), + Ok("MemAvailable: 1 kB\n".into()), + ), + ( + PathBuf::from("/proc/self/cgroup"), + Ok(format!("0::/{GROUP}\n")), + ), + ( + Path::new(ROOT).join(GROUP).join("memory.max"), + Ok("900".into()), + ), + ( + Path::new(ROOT).join(GROUP).join("memory.current"), + Ok("100".into()), + ), + ( + Path::new(ROOT).join("PRIVATE_CGROUP_CANARY/memory.max"), + Ok("700".into()), + ), + ( + Path::new(ROOT).join("PRIVATE_CGROUP_CANARY/memory.current"), + Ok("200".into()), + ), + ( + Path::new(ROOT).join("cgroup.controllers"), + Ok("cpu memory io".into()), + ), + ]) +} + +fn read(files: &Files, path: &Path) -> Result { + files.get(path).cloned().unwrap_or(Err(ErrorKind::NotFound)) +} + +// Frozen decision algorithm from 3630ff79, independent of the new observer. +fn original(files: &Files, calls: &RefCell>) -> Option { + let read = |path: &Path| { + calls.borrow_mut().push(path.to_owned()); + read(files, path) + }; + let mut available = super::super::available_memory(&read(Path::new("/proc/meminfo")).ok()?)?; + let relative = super::super::unified_path(&read(Path::new("/proc/self/cgroup")).ok()?)?; + let root = Path::new(ROOT); + let mut path = root.join(relative); + loop { + match read(&path.join("memory.max")) { + Ok(limit) => { + let current = if limit.trim() == "max" { + None + } else { + Some(read(&path.join("memory.current")).ok()?) + }; + available = super::super::capped_memory(available, &limit, current.as_deref())?; + } + Err(ErrorKind::NotFound) if path == root => { + let controllers = read(&path.join("cgroup.controllers")).ok()?; + if !controllers.split_whitespace().any(|name| name == "memory") { + return None; + } + } + Err(ErrorKind::NotFound) => { + let parent = path.parent().filter(|parent| parent.starts_with(root))?; + let controllers = read(&parent.join("cgroup.subtree_control")).ok()?; + if controllers.split_whitespace().any(|name| name == "memory") { + return None; + } + } + Err(_) => return None, + } + if path == root { + return Some(available); + } + if !path.pop() || !path.starts_with(root) { + return None; + } + } +} + +fn differential(files: &Files) { + let original_calls = RefCell::new(Vec::new()); + let expected = original(files, &original_calls); + for stats in [false, true] { + let calls = RefCell::new(Vec::new()); + let observed = sample_with(stats, &|path| { + calls.borrow_mut().push(path.to_owned()); + read(files, path) + }); + assert_eq!(observed.headroom, expected); + let calls = calls.into_inner(); + if stats && expected.is_some() { + assert!(calls.starts_with(&original_calls.borrow())); + assert!( + calls[original_calls.borrow().len()..] + .iter() + .all(|path| path.file_name().unwrap() == "memory.stat") + ); + } else { + assert_eq!(calls, *original_calls.borrow()); + } + } +} + +#[test] +fn admission_sampler_preserves_original_decisions_and_read_order() { + let variants = [ + Ok("max".into()), + Ok("0".into()), + Ok("100".into()), + Ok(u64::MAX.to_string()), + Ok("+1".into()), + Ok("PRIVATE_VALUE_CANARY".into()), + Err(ErrorKind::NotFound), + Err(ErrorKind::PermissionDenied), + ]; + for own in &variants { + for used in &variants { + for parent in &variants { + let mut files = files(); + files.insert(Path::new(ROOT).join(GROUP).join("memory.max"), own.clone()); + files.insert( + Path::new(ROOT).join(GROUP).join("memory.current"), + used.clone(), + ); + files.insert( + Path::new(ROOT).join("PRIVATE_CGROUP_CANARY/memory.max"), + parent.clone(), + ); + differential(&files); + } + } + } +} + +#[test] +fn admission_sampler_keeps_original_text_and_controller_rejections() { + for (path, variants) in [ + ( + "/proc/meminfo", + vec![ + "", + "MemAvailable: +1 kB", + "MemAvailable: 1 MB", + "MemAvailable: 1 kB extra", + "MemAvailable: 1 kB\nMemAvailable: 2 kB", + "MemAvailable: 18446744073709551615 kB", + ], + ), + ( + "/proc/self/cgroup", + vec![ + "", + "0::relative", + "0::/a/../b", + "0::/a/./b", + "0::/a//b", + "0::/a\n0::/b", + "1:memory:/scope", + "0::/", + ], + ), + ( + "/sys/fs/cgroup/cgroup.controllers", + vec!["", "cpu", "memory", "cpu memory io"], + ), + ] { + for raw in variants { + let mut files = files(); + files.insert(PathBuf::from(path), Ok(raw.into())); + differential(&files); + } + } + for controller in ["", "cpu io", "memory", "cpu memory io"] { + let mut files = files(); + files.remove(&Path::new(ROOT).join(GROUP).join("memory.max")); + files.insert( + Path::new(ROOT).join("PRIVATE_CGROUP_CANARY/cgroup.subtree_control"), + Ok(controller.into()), + ); + differential(&files); + } + let mut deep = files(); + deep.insert( + PathBuf::from("/proc/self/cgroup"), + Ok(format!("0::/{}", vec!["a"; 65].join("/"))), + ); + differential(&deep); + let files = files(); + for path in files.keys().cloned().collect::>() { + for error in [ + ErrorKind::NotFound, + ErrorKind::PermissionDenied, + ErrorKind::InvalidData, + ErrorKind::Other, + ] { + let mut changed = files.clone(); + changed.insert(path.clone(), Err(error)); + differential(&changed); + } + } +} + +#[test] +fn closed_snapshot_identifies_minimum_and_ties_without_cgroup_names() { + for (host, own, parent, scope) in [ + (100, 800, 500, "host"), + (900, 100, 500, "self"), + (900, 800, 100, "ancestor"), + (100, 100, 500, "multiple"), + ] { + let sample = Sample { + headroom: Some(host.min(own).min(parent)), + host: Some(host), + own: Some(own), + ancestor: Some(parent), + ..Sample::default() + }; + assert_eq!(sample.snapshot()["limiting_scope"], scope); + } + let sample = sample_with(true, &|path| read(&files(), path)); + let value = sample.snapshot(); + assert_eq!(value["headroom_bytes"], 500); + assert_eq!(value["self_current_bytes"], 100); + assert_eq!(value["self_max_bytes"], 900); + assert_eq!(value["ancestor_current_bytes"], 200); + assert_eq!(value["ancestor_max_bytes"], 700); + assert_eq!(value["limiting_scope"], "ancestor"); + assert!(!value.to_string().contains("CANARY")); +} + +#[test] +fn missing_samples_preserve_only_closed_failure_stage_and_io_kind() { + for (path, stage) in [ + ("/proc/meminfo", "meminfo"), + ("/proc/self/cgroup", "cgroup_path"), + ( + "/sys/fs/cgroup/PRIVATE_CGROUP_CANARY/child/memory.max", + "max", + ), + ( + "/sys/fs/cgroup/PRIVATE_CGROUP_CANARY/child/memory.current", + "current", + ), + ("/sys/fs/cgroup/cgroup.controllers", "controller"), + ] { + let mut files = files(); + files.insert(PathBuf::from(path), Err(ErrorKind::PermissionDenied)); + let sample = sample_with(true, &|path| read(&files, path)); + let value = sample.snapshot(); + assert_eq!(sample.headroom, None); + assert_eq!(value["missing_stage"], stage); + assert_eq!(value["io_kind"], "permission"); + assert_eq!(value["limiting_scope"], "unknown"); + assert!(value["headroom_bytes"].is_null()); + assert!(!value.to_string().contains("CANARY")); + } +} + +#[test] +fn optional_stat_reads_only_self_and_selected_ancestor_after_the_decision_sample() { + let mut files = files(); + let own = Path::new(ROOT).join(GROUP).join("memory.stat"); + let ancestor = Path::new(ROOT).join("PRIVATE_CGROUP_CANARY/memory.stat"); + files.insert(own.clone(), Ok(format!("{STAT}PRIVATE_STAT_CANARY 999\n"))); + files.insert(ancestor.clone(), Ok(STAT.into())); + let calls = RefCell::new(Vec::new()); + let sample = sample_with(true, &|path| { + calls.borrow_mut().push(path.to_owned()); + read(&files, path) + }); + let calls = calls.into_inner(); + assert_eq!(&calls[calls.len() - 2..], &[own, ancestor]); + assert_eq!(sample.headroom, Some(500)); + assert_eq!(sample.snapshot()["self_stat"]["file_bytes"], 50); + assert_eq!(sample.snapshot()["ancestor_stat"]["anon_bytes"], 40); + assert!(!sample.snapshot().to_string().contains("CANARY")); + differential(&files); +} + +#[test] +fn absent_invalid_or_overflowing_stats_never_change_headroom() { + for (raw, state) in [ + (Err(ErrorKind::PermissionDenied), "unavailable"), + (Ok(String::new()), "invalid"), + (Ok(format!("{STAT}anon 1\n")), "invalid"), + (Ok(STAT.replace("anon 40", "anon +40")), "invalid"), + (Ok(STAT.replace("file 50", "file 50 extra")), "invalid"), + ( + Ok(STAT.replace("file 50", &format!("file {}", u64::MAX))), + "unrepresentable", + ), + ] { + let mut files = files(); + files.insert(Path::new(ROOT).join(GROUP).join("memory.stat"), raw); + let sample = sample_with(true, &|path| read(&files, path)); + assert_eq!(sample.headroom, Some(500)); + let snapshot = sample.snapshot(); + let stat = &snapshot["self_stat"]; + assert_eq!(stat["state"], state); + assert!( + stat.as_object() + .unwrap() + .iter() + .all(|(key, value)| key == "state" || value.is_null()) + ); + assert_eq!(snapshot["numeric_overflow"], state == "unrepresentable"); + } +} + +#[test] +fn json_bound_does_not_change_internal_u64_admission_value() { + for value in [JSON_MAX, JSON_MAX + 1, u64::MAX] { + let sample = Sample::for_test(Some(value)); + let snapshot = sample.snapshot(); + assert_eq!(sample.headroom, Some(value)); + assert_eq!(snapshot["numeric_overflow"], value > JSON_MAX); + assert_eq!(snapshot["headroom_bytes"].is_null(), value > JSON_MAX); + assert_eq!(snapshot["missing_stage"], "none"); + assert!(crate::compute::resources::admits( + crate::compute::ModelProfile::Qwen4bInstruct2507, + sample.headroom + )); + } +} diff --git a/crates/volparossa/src/compute/supervise.rs b/crates/volparossa/src/compute/supervise.rs index fe9df1b6f..88a67568c 100644 --- a/crates/volparossa/src/compute/supervise.rs +++ b/crates/volparossa/src/compute/supervise.rs @@ -2,6 +2,7 @@ pub(super) mod diagnostic; mod lifetime; +pub(super) mod native_session; use std::{ collections::BTreeSet, fmt, fs::File, io::Read, os::unix::process::ExitStatusExt, path::Path, @@ -401,6 +402,15 @@ fn check_public_contract(value: &Value, options: &Options) -> Result<()> { } fn check_model_backend(value: &Value, request: &WorkerRequest) -> Result<()> { + request.native_backend.check_report(value)?; + if request.native_backend.enabled() { + ensure!( + request.model_profile == super::ModelProfile::Qwen4bInstruct2507 + && request.mode == Mode::PrivateConversation, + "compute_native_backend_scope" + ); + return Ok(()); + } if matches!( request.model_profile, super::ModelProfile::Smol1700 @@ -432,6 +442,10 @@ fn check_result(value: &Value, request: &WorkerRequest, status: ExitStatus) -> R if value.get("status").and_then(Value::as_str) == Some("error") { return Err(worker_failure(value, &request.id, status)); } + check_successful_result(value, request) +} + +fn check_successful_result(value: &Value, request: &WorkerRequest) -> Result<()> { ensure!( value.get("status").and_then(Value::as_str) == Some("ok"), "compute_backend_failed" @@ -1175,6 +1189,7 @@ mod tests { } let options = Options { mode: Mode::AggregateAdapter, + native_backend: super::super::native_cpu::Options::default(), model_profile: crate::compute::ModelProfile::default(), runtime_root: root.path().join("unused-runtime"), model_root: root.path().join("unused-model"), @@ -1208,6 +1223,7 @@ mod tests { let directory = tempfile::tempdir().unwrap(); let options = Options { mode: Mode::PlanTasks, + native_backend: super::super::native_cpu::Options::default(), model_profile: crate::compute::ModelProfile::default(), runtime_root: directory.path().join("unused-runtime"), model_root: directory.path().join("unused-model"), @@ -1253,6 +1269,7 @@ mod tests { // Inert result-contract inputs, not model execution or answer-quality evidence. for mode in [Mode::Infer, Mode::Train] { let request = WorkerRequest { + native_backend: super::super::native_cpu::Request::default(), version: 1, id: "abc".into(), mode, @@ -1290,6 +1307,7 @@ mod tests { fn larger_profile_requires_actual_precision_and_distinguishes_tokenizer_only_work() { // Inert protocol evidence only; this does not instantiate a model. let mut request = WorkerRequest { + native_backend: super::super::native_cpu::Request::default(), version: 1, id: "abc".into(), mode: Mode::Infer, diff --git a/crates/volparossa/src/compute/supervise/diagnostic.rs b/crates/volparossa/src/compute/supervise/diagnostic.rs index 4604976a3..ba2b1331b 100644 --- a/crates/volparossa/src/compute/supervise/diagnostic.rs +++ b/crates/volparossa/src/compute/supervise/diagnostic.rs @@ -9,6 +9,38 @@ use std::sync::Mutex; mod generation; const FIXED_CODES: &[&str] = &[ + // Private-service setup/result boundaries outside the worker supervisor. + // Exact equality only: prefixes and path-bearing messages remain unknown. + "compute_private_budget", + "compute_model_memory_headroom", + "compute_runtime_busy", + "compute_lock_invalid", + "compute_output_create", + "compute_sandbox_spawn", + "compute_sandbox_prerequisite", + "compute_runtime_missing", + "compute_native_backend_authorization", + "compute_native_backend_manifest_hash", + "compute_native_backend_manifest_schema", + "compute_native_backend_identity", + "compute_native_backend_artifacts", + "compute_native_backend_conversion", + "compute_native_backend_file", + "compute_native_backend_scope", + "compute_native_backend_policy", + "compute_native_backend_output_constraint", + "compute_private_result_scope", + "compute_private_result_input", + "compute_private_result_model", + "compute_private_result_model_weights", + "compute_private_result_outputs", + "compute_private_result_output", + "compute_native_backend_result_binding", + "compute_native_backend_result_precision", + "compute_native_backend_output_constraint_binding", + "conversation_generation_policy_binding", + "conversation_output_constraint_binding", + "conversation_report_binding", "compute_deadline", "compute_owner_busy", "compute_memory_budget", @@ -55,7 +87,12 @@ const FIXED_CODES: &[&str] = &[ "conversation_native_marker", "conversation_native_call", "conversation_native_preface", + // Retained for original receipts; new JSON rejections use closed categories. "conversation_native_json", + "conversation_native_json_syntax", + "conversation_native_json_data", + "conversation_native_json_eof", + "conversation_native_json_io", "conversation_unknown_tool", "conversation_custom_input", "conversation_arguments", @@ -242,9 +279,22 @@ const WORKER_CODES: &[&str] = &[ "RESULT_TOO_LARGE", "JOB_CANCELLED", "JOB_DEADLINE_EXCEEDED", + "NATIVE_ABI_MISMATCH", + "NATIVE_BUILD_PATCH_BINDING", + "NATIVE_OPEN_DIAGNOSTIC_INVALID", + "NATIVE_OPEN_INVALID_ARGUMENT", + "NATIVE_CPU_UNSUPPORTED", + "NATIVE_BACKEND_INITIALIZATION_FAILED", + "NATIVE_MODEL_INITIALIZATION_FAILED", + "NATIVE_VOCABULARY_MISMATCH", + "NATIVE_CONTEXT_INITIALIZATION_FAILED", + "NATIVE_SAMPLER_INITIALIZATION_FAILED", + "NATIVE_MODEL_LOAD_FAILED", + "NATIVE_OWNER_CONTROL_FAILED", + "INVALID_NATIVE_CONTROL", ]; -#[derive(Debug, Serialize, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, Serialize, PartialEq, Eq)] pub(super) struct Detail { code: &'static str, io_kind: &'static str, @@ -254,6 +304,12 @@ pub(super) struct Detail { } pub(super) fn describe(error: &Error) -> Detail { + if let Some(detail) = super::native_session::diagnostic(error) { + return detail; + } + if let Some(error) = error.downcast_ref::() { + return error.0; + } let io_kind = error .chain() .find_map(|cause| cause.downcast_ref::()) @@ -296,6 +352,95 @@ pub(super) fn describe(error: &Error) -> Detail { detail } +// Selected-session diagnostics only. Legacy FIXED_CODES/WORKER_CODES and their +// historical projections remain unchanged. Exact literals, never a code prefix +// or arbitrary uppercase text, are the complete additional export authority. +const SESSION_CODES: &[&str] = &[ + "compute_native_session_child_exited", + "compute_native_session_cpu_backwards", + "compute_native_session_cpu_bound", + "compute_native_session_cpu_budget", + "compute_native_session_cpu_clock", + "compute_native_session_cpu_identity", + "compute_native_session_cpu_stat", + "compute_native_session_receiver", + "compute_native_session_report", + "compute_native_session_request_bound", + "compute_native_session_root", + "compute_native_session_stderr", + "compute_native_session_stderr_closed", + "compute_native_session_stdout_closed", + "compute_native_session_turn_root", + "compute_native_session_unrequested_idle", + "compute_native_session_unsupported", + "compute_native_session_worker", + "compute_worker_invalid_json", + "compute_output_after_result", + "compute_result_missing", +]; +const SESSION_WORKER_CODES: &[&str] = &[ + "NATIVE_DECODE_FAILED", + "NATIVE_SAMPLE_FAILED", + "NATIVE_SESSION_PREFIX_RESET_FAILED", + "NATIVE_SESSION_CPU_OBSERVATION", + "NATIVE_SESSION_CPU_BOUND", + "NATIVE_SESSION_DEADLINE", + "NATIVE_SESSION_MODEL_CHANGED", + "NATIVE_SESSION_PREFIX_BOUND", + "INVALID_NATIVE_SESSION_FINISH", + "INVALID_NATIVE_SESSION_SEQUENCE", +]; + +pub(super) fn native_session_detail(error: &Error) -> Detail { + let mut detail = describe(error); + if detail.code == "unclassified" { + for cause in error.chain() { + let message = cause.to_string(); + if let Some(code) = SESSION_CODES.iter().copied().find(|code| *code == message) { + detail.code = code; + break; + } + } + } + detail +} + +// Unlike WorkerFailure, this has no exit-status, adapter-quarantine, request-ID +// or cleanup authority. It retains only the independently closed projection. +#[derive(Debug)] +struct ReceivedNativeWorkerFailure(Detail); + +impl std::fmt::Display for ReceivedNativeWorkerFailure { + fn fmt(&self, output: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + output.write_str("compute_native_worker_error") + } +} + +impl std::error::Error for ReceivedNativeWorkerFailure {} + +pub(super) fn native_worker_failure(value: &Value, id: &str) -> Option { + if value.get("version") != Some(&Value::from(1)) + || value.get("id").and_then(Value::as_str) != Some(id) + || value.get("kind").and_then(Value::as_str) != Some("result") + || value.get("status").and_then(Value::as_str) != Some("error") + { + return None; + } + let code = WORKER_CODES + .iter() + .chain(SESSION_WORKER_CODES) + .copied() + .find(|code| value.get("code").and_then(Value::as_str) == Some(*code)) + .unwrap_or("worker_other"); + Some(Error::new(ReceivedNativeWorkerFailure(Detail { + code, + io_kind: "none", + exit_code: None, + signal: None, + stderr_class: None, + }))) +} + pub(in crate::compute) fn failure(phase: &'static str, error: &Error) { emit(phase, &describe(error)); } @@ -325,6 +470,79 @@ fn emit(phase: &'static str, detail: &Detail) { mod tests { use super::*; + #[test] + fn session_codes_are_exact_and_do_not_change_legacy_error_projection() { + for code in SESSION_CODES { + let original = anyhow::anyhow!("PRIVATE_CANARY /private/prompt").context(*code); + assert_eq!(describe(&original).code, "unclassified"); + let detail = native_session_detail(&original); + assert_eq!(detail.code, *code); + let raw = serde_json::to_string(&detail).unwrap(); + assert!(!raw.contains("PRIVATE_CANARY") && !raw.contains("/private/")); + let altered = anyhow::anyhow!("{code} PRIVATE_CANARY"); + assert_eq!(native_session_detail(&altered).code, "unclassified"); + } + } + + #[test] + fn received_native_codes_require_exact_correlation_and_never_invent_exit_facts() { + let base = serde_json::json!({"version":1,"id":"PRIVATE_REQUEST_CANARY", + "kind":"result","status":"error","code":"NATIVE_DECODE_FAILED"}); + for code in WORKER_CODES.iter().chain(SESSION_WORKER_CODES) { + let mut value = base.clone(); + value["code"] = (*code).into(); + let error = native_worker_failure(&value, "PRIVATE_REQUEST_CANARY").unwrap(); + assert_eq!( + describe(&error), + Detail { + code, + io_kind: "none", + exit_code: None, + signal: None, + stderr_class: None, + } + ); + assert_eq!(error.chain().count(), 1); + assert!(super::super::adapter_violation(&error).is_none()); + assert!(super::super::native_session::cleanup(&error).is_none()); + } + for (key, changed) in [ + ("version", serde_json::json!(true)), + ("version", serde_json::json!(2)), + ("id", serde_json::json!("OTHER_PRIVATE_REQUEST")), + ("kind", serde_json::json!("progress")), + ("status", serde_json::json!("ok")), + ] { + let mut value = base.clone(); + value[key] = changed; + assert!(native_worker_failure(&value, "PRIVATE_REQUEST_CANARY").is_none()); + } + } + + #[test] + fn native_worker_unknown_codes_and_extra_private_fields_never_escape_projection() { + for code in [ + serde_json::json!("PRIVATE_CANARY"), + serde_json::json!("NATIVE_DECODE_FAILED /private/PRIVATE_CANARY"), + serde_json::json!("NATIVE_DECODE_FAILED\nPRIVATE_CANARY"), + serde_json::json!(true), + serde_json::json!({"private":"PRIVATE_CANARY"}), + Value::Null, + ] { + let value = serde_json::json!({"version":1,"id":"PRIVATE_REQUEST_CANARY", + "kind":"result","status":"error","code":code, + "message":"PRIVATE_CANARY /private/input", "exit_code":42, "signal":9, + "cleanup":{"child_reaped":true}}); + let error = native_worker_failure(&value, "PRIVATE_REQUEST_CANARY").unwrap(); + let detail = describe(&error); + assert_eq!(detail.code, "worker_other"); + assert_eq!(detail.exit_code, None); + assert_eq!(detail.signal, None); + let raw = serde_json::to_string(&detail).unwrap(); + assert!(!raw.contains("CANARY") && !raw.contains("/private/") && !raw.contains("42")); + } + } + fn progress(stage: &str, observation: &str, elapsed_ms: u64) -> Value { let index = PRIVATE_STAGES .iter() @@ -548,6 +766,19 @@ assert result == dict(version=1, truncated=False, unrecognized_record=False, sta assert!(result.stdout.is_empty() && result.stderr.is_empty()); } + #[test] + fn fixed_task_codes_require_exact_equality_and_do_not_disclose_context() { + for code in FIXED_CODES { + let error = anyhow::anyhow!("PRIVATE_TASK_CANARY /private/input").context(*code); + let detail = describe(&error); + assert_eq!(detail.code, *code); + let raw = serde_json::to_string(&detail).unwrap(); + assert!(!raw.contains("PRIVATE_TASK_CANARY") && !raw.contains("/private/input")); + let altered = anyhow::anyhow!("{code} PRIVATE_TASK_CANARY"); + assert_eq!(describe(&altered).code, "unclassified"); + } + } + #[test] fn diagnostics_keep_operation_and_errno_but_never_arbitrary_error_text() { let error = Error::new(std::io::Error::new( @@ -580,6 +811,9 @@ assert result == dict(version=1, truncated=False, unrecognized_record=False, sta "BACKEND_IMPORT_FAILED" ); assert_eq!(describe(&failure("JOB_CANCELLED")).code, "JOB_CANCELLED"); + for code in WORKER_CODES.iter().filter(|code| code.contains("NATIVE")) { + assert_eq!(describe(&failure(code)).code, *code); + } assert_eq!( describe(&failure("JOB_DEADLINE_EXCEEDED")).code, "JOB_DEADLINE_EXCEEDED" diff --git a/crates/volparossa/src/compute/supervise/lifetime.rs b/crates/volparossa/src/compute/supervise/lifetime.rs index ad23540e8..60c9358c7 100644 --- a/crates/volparossa/src/compute/supervise/lifetime.rs +++ b/crates/volparossa/src/compute/supervise/lifetime.rs @@ -107,6 +107,35 @@ impl OwnedLifetimes { .await; } } + + /// Aggregate process CPU for this exact still-observable tree. Each record + /// includes its own waited-child counters, but a previously observed member + /// disappearing is conservatively an error, never invented CPU credit. + pub(super) fn cpu_ticks(&self) -> Result { + ensure!( + !self.observation_failed, + "compute_private_process_observation" + ); + let mut total = 0_u64; + for expected in &self.members { + let root = Path::new("/proc").join(expected.pid.to_string()); + let raw = super::system_text(&root.join("stat"))?; + ensure!( + parse_record(expected.pid, &raw)?.identity == *expected, + "compute_native_session_cpu_identity" + ); + let (_, tail) = raw + .rsplit_once(") ") + .context("compute_native_session_cpu_stat")?; + let fields: Vec<_> = tail.split_whitespace().collect(); + for index in [11, 12, 13, 14] { + total = total + .checked_add(fields[index].parse::()?) + .context("compute_native_session_cpu_bound")?; + } + } + Ok(total) + } } fn same_lifetime(expected: Identity) -> Result { @@ -203,6 +232,7 @@ mod tests { let tracker = OwnedLifetimes::capture(pid).unwrap(); child.kill().await.unwrap(); assert!(timed_out.is_err()); + assert!(tracker.cpu_ticks().is_err()); tracker .wait_closed(Instant::now() + Duration::from_secs(1)) .await diff --git a/crates/volparossa/src/compute/supervise/native_session.rs b/crates/volparossa/src/compute/supervise/native_session.rs new file mode 100644 index 000000000..f2dff0294 --- /dev/null +++ b/crates/volparossa/src/compute/supervise/native_session.rs @@ -0,0 +1,334 @@ +//! A single same-owner IPC connection owns one resident native worker. +//! Per-turn files and whole-process cleanup have deliberately different receipts. + +#[cfg(test)] +mod tests; +mod worker; + +use std::{ + fmt, fs, + io::Write as _, + os::unix::fs::{DirBuilderExt as _, OpenOptionsExt as _, PermissionsExt as _}, + path::Path, + sync::Arc, + time::Duration, +}; + +use anyhow::{Context as _, Result, ensure}; +use serde_json::{Value, json}; +use tokio::{ + sync::{mpsc, watch}, + time::Instant, +}; + +use crate::compute::{ + Mode, Options, WorkerRequest, native_cpu, private_conversation, private_task, resources, + runtime_lease, +}; + +pub(in crate::compute) const MAX_TASK_SECONDS: u64 = 2400; +pub(in crate::compute) const MAX_IDLE_SECONDS: u64 = 300; +pub(in crate::compute) const MAX_REQUESTS: u16 = 128; + +pub(in crate::compute) enum Command { + Submit { id: String, input: Vec }, + Close, +} + +pub(in crate::compute) enum Event { + Opened, + Result { id: String, value: Value }, +} + +#[derive(Debug)] +struct Failure { + cause: anyhow::Error, + diagnostic: super::diagnostic::Detail, +} + +impl Failure { + // Called only after the original process join and private-root removal. + // Keep the cause private for the existing deadline predicate; its chain is + // deliberately not exposed through Error::source or public wire messages. + fn after_cleanup(cause: anyhow::Error) -> Self { + let diagnostic = super::diagnostic::native_session_detail(&cause); + Self { cause, diagnostic } + } +} + +impl fmt::Display for Failure { + fn fmt(&self, output: &mut fmt::Formatter<'_>) -> fmt::Result { + output.write_str("compute_native_session_execution_failed") + } +} + +impl std::error::Error for Failure {} + +pub(super) fn diagnostic(error: &anyhow::Error) -> Option { + error + .downcast_ref::() + .map(|failure| failure.diagnostic) +} + +fn cleanup_record() -> Value { + json!({"version":1,"child_reaped":true,"private_state_removed":true}) +} + +/// Only this private type is constructed after both independent cleanup steps. +pub(in crate::compute) fn cleanup(error: &anyhow::Error) -> Option { + error.downcast_ref::().map(|_| cleanup_record()) +} + +pub(in crate::compute) fn execution_deadline(error: &anyhow::Error) -> bool { + error.downcast_ref::().is_some_and(|failure| { + failure + .cause + .downcast_ref::() + .is_some() + }) +} + +struct PrivateRoot(Option); + +impl PrivateRoot { + fn new(parent: &Path) -> Result { + crate::compute::private_directory(parent)?; + Ok(Self(Some( + tempfile::Builder::new() + .prefix("private-native-session-") + .permissions(fs::Permissions::from_mode(0o700)) + .tempdir_in(parent)?, + ))) + } + + fn path(&self) -> &Path { + self.0 + .as_ref() + .expect("owned session root exists until explicit cleanup") + .path() + } + + fn close(mut self) -> Result<()> { + self.0 + .take() + .context("compute_native_session_root")? + .close() + .map_err(|_| anyhow::anyhow!("compute_private_storage_cleanup_failed")) + } +} + +impl Drop for PrivateRoot { + fn drop(&mut self) { + // Panic/cancellation cannot unlink files still mounted by an unjoined child. + if let Some(root) = self.0.take() { + let _ = root.keep(); + } + } +} + +fn stage( + config: &private_task::ExecutionConfig, + root: &Path, + turn: u16, + input: &[u8], +) -> Result { + config + .native_backend + .validate_input(Mode::PrivateConversation, input, config.model_profile)?; + let directory = root.join(format!("turn-{turn}")); + fs::DirBuilder::new().mode(0o700).create(&directory)?; + let dataset = directory.join("input.json"); + let mut file = fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC) + .open(&dataset)?; + file.write_all(input)?; + file.sync_all()?; + let output = directory.join("output"); + fs::DirBuilder::new().mode(0o700).create(&output)?; + Ok(Options { + mode: Mode::PrivateConversation, + runtime_root: config.runtime_root.clone(), + model_root: config.model_root.clone(), + model_profile: config.model_profile, + native_backend: config.native_backend.clone(), + adapter_root: None, + dataset, + output, + steps: 1, + threads: config.threads, + max_seconds: config.max_seconds, + spare_capacity: true, + execute: true, + }) +} + +fn request(options: &Options, id: String) -> Result { + Ok(WorkerRequest { + native_backend: options.native_backend.request_for_dataset( + options.model_profile, + Mode::PrivateConversation, + &options.dataset, + )?, + version: 1, + id, + mode: Mode::PrivateConversation, + model_profile: options.model_profile, + model_root: "/model", + dataset_path: "/dataset.json", + output_root: "/output", + adapter_root: None, + steps: 1, + threads: options.threads, + max_seconds: options.max_seconds, + owner_control: true, + private_generation_diagnostics: tracing::enabled!(target: "volparossa::compute::private_diagnostic", tracing::Level::DEBUG), + }) +} + +struct Resident { + worker: Option, + hints: Option, + turn: u16, +} + +impl Resident { + #[allow( + clippy::too_many_arguments, + reason = "Task and per-turn identities remain explicit" + )] + async fn submit( + &mut self, + config: &private_task::ExecutionConfig, + root: &Path, + id: String, + input: &[u8], + task_deadline: Instant, + activity: &mut watch::Receiver, + ) -> Result { + ensure!( + self.turn < MAX_REQUESTS, + "compute_native_session_request_bound" + ); + self.turn += 1; + let deadline = (Instant::now() + Duration::from_secs(u64::from(config.max_seconds))) + .min(task_deadline); + let options = stage(config, root, self.turn, input)?; + let request = request(&options, id)?; + if self.worker.is_none() { + resources::admission(config.model_profile, true)?; + ensure!(*activity.borrow(), "compute_owner_busy"); + self.hints = Some(native_cpu::SessionHints::bind( + &request.native_backend, + &options, + )); + self.worker = Some(worker::Live::spawn(&options, root)?); + } + let report = self + .worker + .as_mut() + .context("compute_native_session_worker")? + .turn(&request, &options, self.turn, deadline, activity) + .await?; + private_task::validate_native_session_report(&report, input, config.model_profile)?; + // The worker has acknowledged its no-more-IO turn boundary, not exited. + let turn_root = options + .dataset + .parent() + .context("compute_native_session_turn_root")?; + fs::remove_dir_all(turn_root) + .map_err(|_| anyhow::anyhow!("compute_private_storage_cleanup_failed"))?; + let mut value = private_conversation::summary(&report, config.model_profile); + value["version"] = json!(2); + value["cleanup"] = json!({"version":2,"request_files_removed":true, + "session_closed":false,"session_private_state_retained":true}); + Ok(value) + } +} + +async fn serve( + config: &private_task::ExecutionConfig, + root: &Path, + resident: &mut Resident, + commands: &mut mpsc::Receiver, + events: &mpsc::Sender, + activity: &mut watch::Receiver, +) -> Result<()> { + let task_deadline = Instant::now() + Duration::from_secs(MAX_TASK_SECONDS); + let mut idle_deadline = Instant::now() + Duration::from_secs(MAX_IDLE_SECONDS); + let mut ticks = tokio::time::interval(Duration::from_millis(250)); + ticks.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + events + .send(Event::Opened) + .await + .context("compute_native_session_receiver")?; + loop { + tokio::select! { + biased; + () = tokio::time::sleep_until(task_deadline.min(idle_deadline)) => return Ok(()), + changed = activity.changed() => { + ensure!(changed.is_ok() && *activity.borrow(), "compute_owner_busy"); + }, + command = commands.recv() => match command { + Some(Command::Submit { id, input }) => { + let value = resident.submit(config, root, id.clone(), &input, task_deadline, activity).await?; + events.send(Event::Result { id, value }).await.context("compute_native_session_receiver")?; + idle_deadline = Instant::now() + Duration::from_secs(MAX_IDLE_SECONDS); + }, + Some(Command::Close) | None => return Ok(()), + }, + _ = ticks.tick() => if let Some(worker) = resident.worker.as_mut() { + worker.idle(root, config.model_profile)?; + }, + } + } +} + +pub(in crate::compute) async fn run( + config: Arc, + mut commands: mpsc::Receiver, + events: mpsc::Sender, + mut activity: watch::Receiver, +) -> Result { + ensure!( + !nix::unistd::geteuid().is_root(), + "compute_unprivileged_user_required" + ); + config.validate()?; + ensure!( + config + .native_backend + .supports_native_session(config.model_profile), + "compute_native_session_unsupported" + ); + let _lease = runtime_lease(&config.runtime_root)?; + let root = PrivateRoot::new(&config.work_parent)?; + let mut resident = Resident { + worker: None, + hints: None, + turn: 0, + }; + let result = serve( + &config, + root.path(), + &mut resident, + &mut commands, + &events, + &mut activity, + ) + .await; + if let Some(worker) = resident.worker.take() { + // This join is mandatory even when the client disconnects or a validator fails. + worker.close().await?; + } + root.close()?; + let result = result + .map(|()| cleanup_record()) + .map_err(|cause| anyhow::Error::new(Failure::after_cleanup(cause))); + if let Some(hints) = resident.hints.take() { + hints.finish(&result); + } + result +} diff --git a/crates/volparossa/src/compute/supervise/native_session/tests.rs b/crates/volparossa/src/compute/supervise/native_session/tests.rs new file mode 100644 index 000000000..55cfe1462 --- /dev/null +++ b/crates/volparossa/src/compute/supervise/native_session/tests.rs @@ -0,0 +1,193 @@ +//! Inert lifecycle/authority checks; no namespace, model or native backend. + +use std::{io::Write as _, os::unix::fs::PermissionsExt as _}; + +use super::*; + +#[test] +fn cleanup_is_explicitly_constructed_only_after_join_and_removal() { + let ordinary = anyhow::anyhow!("not a cleanup proof"); + assert_eq!(cleanup(&ordinary), None); + assert_eq!(cleanup(&private_task::CleanupUnconfirmed.into()), None); + let confirmed = anyhow::Error::new(Failure::after_cleanup( + super::super::ExecutionDeadline.into(), + )); + assert_eq!(cleanup(&confirmed), Some(cleanup_record())); + assert!(execution_deadline(&confirmed)); + assert!(!execution_deadline(&ordinary)); + let different = anyhow::Error::new(Failure::after_cleanup(anyhow::anyhow!( + "compute_owner_busy" + ))); + assert!(!execution_deadline(&different)); +} + +#[test] +fn closed_failure_preserves_only_projected_diagnostic_without_opening_its_source() { + let cause = anyhow::Error::new(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "PRIVATE_CANARY /private/source.rs prompt body", + )) + .context("compute_private_process_children"); + let before = super::super::diagnostic::describe(&cause); + let error = anyhow::Error::new(Failure::after_cleanup(cause)); + assert_eq!(super::super::diagnostic::describe(&error), before); + assert_eq!(error.chain().count(), 1); + assert!(std::error::Error::source(error.downcast_ref::().unwrap()).is_none()); + assert_eq!(error.to_string(), "compute_native_session_execution_failed"); + assert_eq!(cleanup(&error), Some(cleanup_record())); + assert!(!execution_deadline(&error)); + let encoded = serde_json::to_string(&diagnostic(&error).unwrap()).unwrap(); + assert!(!encoded.contains("PRIVATE_CANARY") && !encoded.contains("/private/")); +} + +#[test] +fn received_worker_error_cannot_construct_exit_adapter_or_cleanup_authority() { + let value = json!({"version":1,"id":"PRIVATE_REQUEST_CANARY","kind":"result", + "status":"error","code":"NATIVE_DECODE_FAILED"}); + let received = + super::super::diagnostic::native_worker_failure(&value, "PRIVATE_REQUEST_CANARY").unwrap(); + assert_eq!(cleanup(&received), None); + assert!(!execution_deadline(&received)); + assert!(super::super::adapter_violation(&received).is_none()); + let error = anyhow::Error::new(Failure::after_cleanup(received)); + let detail = serde_json::to_value(super::super::diagnostic::describe(&error)).unwrap(); + assert_eq!(detail["code"], "NATIVE_DECODE_FAILED"); + assert_eq!(detail["exit_code"], Value::Null); + assert_eq!(detail["signal"], Value::Null); + assert_eq!(detail["stderr_class"], Value::Null); + assert!(!detail.to_string().contains("PRIVATE_REQUEST_CANARY")); + assert_eq!(cleanup(&error), Some(cleanup_record())); + assert!(!execution_deadline(&error)); + assert!(super::super::adapter_violation(&error).is_none()); + // An independent cleanup failure is never relabelled using this diagnostic. + let unconfirmed = anyhow::Error::new(private_task::CleanupUnconfirmed); + assert_eq!(diagnostic(&unconfirmed), None); + assert_eq!(cleanup(&unconfirmed), None); +} + +#[test] +fn closed_projected_failure_is_accepted_by_both_collector_versions() { + // Serializer/collector contract only: no worker, native backend or cleanup + // is executed by these deliberately fabricated private wrapper instances. + let worker = json!({"version":1,"id":"PRIVATE_REQUEST_CANARY","kind":"result", + "status":"error","code":"NATIVE_SAMPLE_FAILED"}); + let errors = [ + anyhow::anyhow!("PRIVATE_CANARY /private/prompt").context("compute_memory_budget"), + super::super::diagnostic::native_worker_failure(&worker, "PRIVATE_REQUEST_CANARY").unwrap(), + anyhow::anyhow!("PRIVATE_CANARY /private/prompt"), + ]; + let mut log = tempfile::NamedTempFile::new().unwrap(); + for cause in errors { + let error = anyhow::Error::new(Failure::after_cleanup(cause)); + let record = json!({"version":1,"phase":"task", + "detail":super::super::diagnostic::describe(&error)}); + let raw = serde_json::to_string(&record).unwrap(); + assert!(!raw.contains("CANARY") && !raw.contains("/private/")); + writeln!(log, "DEBUG private_execution_diagnostic {raw}").unwrap(); + } + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../tests/integration/agent-private-conversation.py"); + let output = std::process::Command::new("python3") + .args([ + "-B", + "-c", + r" +import runpy, sys +from pathlib import Path +fixture = runpy.run_path(sys.argv[1]) +for enabled in (False, True): + value = fixture['service_diagnostic'](Path(sys.argv[2]), memory_enabled=enabled) + expected = dict(version=2 if enabled else 1, truncated=False, unrecognized_record=False, + events=[dict(version=1, phase='task', detail=dict(code=code, io_kind='none', + exit_code=None, signal=None, stderr_class=None)) for code in + ('compute_memory_budget', 'NATIVE_SAMPLE_FAILED', 'unclassified')]) + if enabled: + expected['memory'] = [] + assert value == expected +", + ]) + .arg(fixture) + .arg(log.path()) + .output() + .unwrap(); + assert!( + output.status.success(), + "closed collector rejected projection" + ); + assert!(output.stdout.is_empty() && output.stderr.is_empty()); +} + +#[test] +fn private_root_drop_preserves_uncertain_state_but_explicit_close_removes_it() { + let parent = tempfile::tempdir().unwrap(); + fs::set_permissions(parent.path(), fs::Permissions::from_mode(0o700)).unwrap(); + let root = PrivateRoot::new(parent.path()).unwrap(); + let retained = root.path().to_owned(); + fs::write(retained.join("inert-canary"), b"private").unwrap(); + assert_eq!( + fs::metadata(&retained).unwrap().permissions().mode() & 0o777, + 0o700 + ); + drop(root); + assert_eq!(fs::read(retained.join("inert-canary")).unwrap(), b"private"); + let root = PrivateRoot::new(parent.path()).unwrap(); + let removed = root.path().to_owned(); + root.close().unwrap(); + assert!(!removed.exists()); + assert!(retained.exists()); +} + +#[test] +fn staged_turn_names_and_permissions_are_core_owned_and_do_not_reuse_files() { + let parent = tempfile::tempdir().unwrap(); + fs::set_permissions(parent.path(), fs::Permissions::from_mode(0o700)).unwrap(); + let root = PrivateRoot::new(parent.path()).unwrap(); + let config = private_task::ExecutionConfig { + runtime_root: "/runtime".into(), + model_root: "/model".into(), + work_parent: parent.path().to_owned(), + model_profile: crate::compute::ModelProfile::Qwen4bInstruct2507, + native_backend: native_cpu::Options::default(), + threads: 2, + max_seconds: 600, + }; + let input = serde_json::to_vec(&json!({ + "version":1,"visibility":"private_local","generation_policy":"greedy_v1", + "instructions":"Use all offered tools when appropriate.","tools":[], + "history":[{"type":"message","role":"user","text":"Check this project."}] + })) + .unwrap(); + let one = stage(&config, root.path(), 1, &input).unwrap(); + assert_eq!(one.dataset, root.path().join("turn-1/input.json")); + assert_eq!(one.output, root.path().join("turn-1/output")); + assert_eq!(fs::read(&one.dataset).unwrap(), input); + assert_eq!( + fs::metadata(&one.dataset).unwrap().permissions().mode() & 0o777, + 0o600 + ); + assert_eq!( + fs::metadata(&one.output).unwrap().permissions().mode() & 0o777, + 0o700 + ); + assert!(stage(&config, root.path(), 1, &input).is_err()); + let two = stage(&config, root.path(), 2, &input).unwrap(); + assert_ne!(one.dataset, two.dataset); + root.close().unwrap(); +} + +#[test] +fn fixed_task_and_turn_bounds_are_distinct_from_legacy_cleanup() { + assert_eq!( + (MAX_TASK_SECONDS, MAX_IDLE_SECONDS, MAX_REQUESTS), + (2400, 300, 128) + ); + let raw = json!({"conversation":{"type":"assistant","text":"ok"}}); + let legacy = + private_conversation::summary(&raw, crate::compute::ModelProfile::Qwen4bInstruct2507); + assert_eq!(legacy["version"], 1); + assert_eq!( + legacy["cleanup"], + json!({"complete":true,"retained_input":false,"retained_report":false}) + ); + assert_ne!(legacy["cleanup"], cleanup_record()); +} diff --git a/crates/volparossa/src/compute/supervise/native_session/worker.rs b/crates/volparossa/src/compute/supervise/native_session/worker.rs new file mode 100644 index 000000000..92fd3b85f --- /dev/null +++ b/crates/volparossa/src/compute/supervise/native_session/worker.rs @@ -0,0 +1,463 @@ +//! Persistent pipe supervision. A turn result is not a process-exit receipt. + +use std::{io::Read as _, path::Path, time::Duration}; + +use anyhow::{Context as _, Result, ensure}; +use serde_json::{Value, json}; +use tokio::{ + io::{AsyncBufReadExt as _, AsyncWriteExt as _, BufReader}, + process::{Child, ChildStdin, ChildStdout}, + sync::watch, + task::JoinHandle, + time::Instant, +}; + +use crate::compute::{ + MAX_LINE_BYTES, MAX_STREAM_BYTES, ModelProfile, Options, WorkerRequest, check_message, + owner_control::{Action, Controls}, + private_conversation::decode_worker_message, + resources, sandbox, + spare_capacity::{Budget, Decision}, + supervise::{self as supervisor, lifetime::OwnedLifetimes}, +}; + +struct Frames { + stream: BufReader, + pending: Vec, + bytes: usize, +} + +impl Frames { + async fn next(&mut self) -> Result { + // Partial bytes belong to this reader, so select cancellation cannot + // silently lose a prefix or splice two differently correlated records. + loop { + let available = self.stream.fill_buf().await?; + ensure!( + !available.is_empty(), + "compute_native_session_stdout_closed" + ); + let newline = available.iter().position(|byte| *byte == b'\n'); + let count = newline.unwrap_or(available.len()); + ensure!( + self.pending.len() + count <= MAX_LINE_BYTES, + "compute_worker_line_size" + ); + self.pending.extend_from_slice(&available[..count]); + self.stream.consume(count + usize::from(newline.is_some())); + if newline.is_some() { + self.bytes = self + .bytes + .checked_add(self.pending.len() + 1) + .context("compute_stdout_size")?; + ensure!(self.bytes <= MAX_STREAM_BYTES, "compute_stdout_size"); + let raw = std::mem::take(&mut self.pending); + return decode_worker_message(&raw) + .map_err(|_| anyhow::anyhow!("compute_worker_invalid_json")); + } + } + } +} + +pub(super) struct Live { + child: Child, + stdin: Option, + stdout: Frames, + stderr: Option>>, + lifetimes: Option>, + pid: u32, + budget: Budget, + peak_rss: u64, + clock_ticks: u64, +} + +fn clock_ticks() -> Result { + // Linux's own bounded auxiliary vector, not a subprocess or guessed USER_HZ. + ensure!(size_of::() == 8, "compute_native_session_cpu_clock"); + let mut bytes = Vec::new(); + std::fs::File::open("/proc/self/auxv")? + .take(8193) + .read_to_end(&mut bytes)?; + ensure!( + bytes.len() <= 8192 && bytes.len() % 16 == 0, + "compute_native_session_cpu_clock" + ); + let mut found = None; + for pair in bytes.chunks_exact(16) { + let key = u64::from_ne_bytes(pair[..8].try_into()?); + let value = u64::from_ne_bytes(pair[8..].try_into()?); + if key == libc::AT_CLKTCK { + ensure!( + found.is_none() && (1..=1_000_000).contains(&value), + "compute_native_session_cpu_clock" + ); + found = Some(value); + } + } + found.context("compute_native_session_cpu_clock") +} + +impl Live { + pub(super) fn spawn(options: &Options, root: &Path) -> Result { + let ticks = clock_ticks()?; + let child = sandbox::native_session_command(options, root) + .spawn() + .context("compute_sandbox_spawn")?; + Ok(Self::own(child, ticks)) + } + + fn own(mut child: Child, ticks: u64) -> Self { + // The fixed builder configures all three pipes. A panic still keeps the + // session tree and quarantines its slot; it cannot fabricate cleanup. + let pid = child.id().expect("newly spawned owned worker has an id"); + let stdin = child.stdin.take().expect("fixed piped stdin"); + let stdout = child.stdout.take().expect("fixed piped stdout"); + let stderr = child.stderr.take().expect("fixed piped stderr"); + Self { + child, + stdin: Some(stdin), + stdout: Frames { + stream: BufReader::new(stdout), + pending: Vec::new(), + bytes: 0, + }, + stderr: Some(tokio::spawn(supervisor::drain_stderr(stderr))), + lifetimes: Some(OwnedLifetimes::capture(pid)), + pid, + budget: Budget::new(), + peak_rss: 0, + clock_ticks: ticks, + } + } + + fn tracker(&mut self) -> Result<&mut OwnedLifetimes> { + self.lifetimes + .as_mut() + .context("compute_private_process_observation")? + .as_mut() + .map_err(|_| anyhow::anyhow!("compute_private_process_observation")) + } + + pub(super) fn idle(&mut self, root: &Path, profile: ModelProfile) -> Result<()> { + ensure!( + self.child.try_wait()?.is_none(), + "compute_native_session_child_exited" + ); + self.tracker()?.refresh()?; + self.peak_rss = self.peak_rss.max(supervisor::observe( + self.pid, + root, + false, + resources::limits(profile).observed_rss, + )?); + ensure!( + self.budget.sample() != Decision::Cancel, + "{}", + self.budget.cancellation_code() + ); + ensure!( + !self.stderr.as_ref().is_some_and(JoinHandle::is_finished), + "compute_native_session_stderr_closed" + ); + Ok(()) + } + + pub(super) async fn turn( + &mut self, + request: &WorkerRequest, + options: &Options, + turn: u16, + deadline: Instant, + activity: &mut watch::Receiver, + ) -> Result { + ensure!( + *activity.borrow() && Instant::now() < deadline, + "compute_owner_busy" + ); + self.tracker()?.refresh()?; + let before = self.tracker()?.cpu_ticks()?; + let controls = Controls::default(); + let progress = supervisor::diagnostic::Progress::with_generation( + request.private_generation_diagnostics, + ); + let mut value = serde_json::to_value(request)?; + value["dataset_path"] = json!(format!("/session/turn-{turn}/input.json")); + value["output_root"] = json!(format!("/session/turn-{turn}/output")); + value["native_session"] = json!({"version":1,"turn":turn}); + let mut bytes = serde_json::to_vec(&value)?; + ensure!(bytes.len() < 65536, "compute_request_limit"); + bytes.push(b'\n'); + bytes.extend( + controls + .issue(supervisor::pressure_action(&mut self.budget)?, &request.id)? + .context("compute_initial_control")?, + ); + self.stdout.bytes = 0; + let result = self + .turn_inner( + request, options, deadline, activity, before, &controls, &progress, &bytes, + ) + .await; + if result.is_err() { + supervisor::diagnostic::terminal_state( + &progress, + &self.budget, + Some(&controls), + self.peak_rss, + ); + } + result + } + + async fn write(&mut self, bytes: &[u8], deadline: Instant) -> Result<()> { + tokio::time::timeout_at( + deadline.min(Instant::now() + Duration::from_secs(1)), + self.stdin + .as_mut() + .context("compute_control_stdin")? + .write_all(bytes), + ) + .await + .context("compute_control_write_deadline")??; + Ok(()) + } + + fn tick(&mut self, options: &Options, before: u64) -> Result<()> { + let root = options + .dataset + .parent() + .and_then(Path::parent) + .context("compute_native_session_root")?; + self.idle(root, options.model_profile)?; + let consumed = self + .tracker()? + .cpu_ticks()? + .checked_sub(before) + .context("compute_native_session_cpu_backwards")?; + let bound = u64::from(options.max_seconds) * u64::from(options.threads) * self.clock_ticks; + ensure!(consumed < bound, "compute_native_session_cpu_budget"); + Ok(()) + } + + #[allow( + clippy::too_many_arguments, + clippy::too_many_lines, + reason = "One original turn deadline surrounds controls, result and quiescence before file removal" + )] + async fn turn_inner( + &mut self, + request: &WorkerRequest, + options: &Options, + deadline: Instant, + activity: &mut watch::Receiver, + before: u64, + controls: &Controls, + progress: &supervisor::diagnostic::Progress, + input: &[u8], + ) -> Result { + self.write(input, deadline).await?; + let mut ticks = tokio::time::interval(Duration::from_millis(250)); + ticks.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + let mut result = None; + loop { + tokio::select! { + biased; + () = tokio::time::sleep_until(deadline) => return Err(supervisor::ExecutionDeadline.into()), + changed = activity.changed() => { + ensure!(changed.is_ok() && *activity.borrow(), "compute_owner_busy"); + }, + record = self.stdout.next() => { + let record = record?; + if record == json!({"version":1,"id":request.id,"kind":"native_turn_idle"}) { + ensure!(result.is_some(), "compute_native_session_unrequested_idle"); + break; + } + let bytes = serde_json::to_vec(&record)?; + let value = check_message(&bytes, &request.id)?; + if value["kind"] == "result" { + ensure!(result.is_none(), "compute_output_after_result"); + controls.terminal()?; + if let Some(error) = supervisor::diagnostic::native_worker_failure(&value, &request.id) { + // A correlated worker error is not proof of its exit, + // cleanup, adapter invalidity or a retry entitlement. + return Err(error); + } + // There is no invented successful exit status here. + supervisor::check_successful_result(&value, request)?; + controls.check_report(&value)?; + supervisor::check_artifacts(&value, options.mode, &options.output)?; + let disk = crate::compute::read_file(&options.output.join("report.json"), MAX_LINE_BYTES as u64)?; + let persisted = decode_worker_message(&disk).map_err(|_| anyhow::anyhow!("compute_native_session_report"))?; + ensure!(persisted == value, "compute_native_session_report"); + result = Some(value); + let mut finish = serde_json::to_vec(&json!({"version":1,"id":request.id,"kind":"native_turn_finish"}))?; + finish.push(b'\n'); + self.write(&finish, deadline).await?; + } else if matches!(value["phase"].as_str(), Some("paused" | "resumed")) { + controls.acknowledge(&value)?; + } else { + ensure!(result.is_none() && value.get("control_sequence").is_none(), "compute_output_after_result"); + progress.observe(&value)?; + } + }, + _ = ticks.tick() => { + self.tick(options, before)?; + if result.is_none() { + // tick() already sampled the unchanged capacity guard. + let action = match self.budget.current() { + Decision::Run => Action::Resume, + Decision::Pause => Action::Pause, + Decision::Cancel => anyhow::bail!("{}", self.budget.cancellation_code()), + }; + if let Some(record) = controls.issue(action, &request.id)? { + self.write(&record, deadline).await?; + } + } + }, + } + } + self.tick(options, before)?; + let mut report = result.context("compute_result_missing")?; + let mut observed = + supervisor::supervisor_report(options, self.peak_rss, Some(controls), &self.budget); + observed["child_reaped"] = json!(false); + observed["native_session_version"] = json!(1); + report["supervisor"] = observed; + Ok(report) + } + + pub(super) async fn close(mut self) -> Result<()> { + let deadline = Instant::now() + Duration::from_secs(3); + if let Some(Ok(tracker)) = self.lifetimes.as_mut() { + let _ = tracker.refresh(); + } + drop(self.stdin.take()); + // The normal EOF path lets native destructors run; cancellation still + // has the exact original three-second cleanup ceiling and owned child. + if tokio::time::timeout(Duration::from_millis(250), self.child.wait()) + .await + .is_err() + { + let _ = self.child.start_kill(); + } + let joined = tokio::time::timeout_at(deadline, self.child.wait()).await; + if !matches!(joined, Ok(Ok(_))) { + return Err(crate::compute::private_task::CleanupUnconfirmed.into()); + } + let lifetime = self + .lifetimes + .take() + .context("compute_private_process_observation")?; + let closed = match lifetime { + Ok(tracker) => tracker.wait_closed(deadline).await.is_ok(), + Err(_) => false, + }; + let mut stderr = self + .stderr + .take() + .context("compute_native_session_stderr")?; + let drained = if let Ok(Ok(Ok(_))) = tokio::time::timeout_at(deadline, &mut stderr).await { + true + } else { + stderr.abort(); + let _ = stderr.await; + false + }; + ensure!( + closed && drained, + crate::compute::private_task::CleanupUnconfirmed + ); + Ok(()) + } +} + +impl Drop for Live { + fn drop(&mut self) { + if let Some(stderr) = self.stderr.take() { + stderr.abort(); + } + } +} + +#[cfg(test)] +mod tests { + use std::process::Stdio; + + use super::*; + + fn child(script: &str) -> Child { + tokio::process::Command::new("/usr/bin/python3") + .args(["-I", "-c", script]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true) + .spawn() + .unwrap() + } + + #[tokio::test] + async fn partial_frame_survives_select_cancellation_without_splicing() { + let mut process = child( + "import sys,time;sys.stdout.write('{\"value\":');sys.stdout.flush();time.sleep(.1);print('1}')", + ); + let mut frames = Frames { + stream: BufReader::new(process.stdout.take().unwrap()), + pending: Vec::new(), + bytes: 0, + }; + let first = tokio::time::timeout(Duration::from_millis(40), frames.next()).await; + assert!(first.is_err()); + assert_eq!( + tokio::time::timeout(Duration::from_secs(2), frames.next()) + .await + .unwrap() + .unwrap(), + json!({"value":1}) + ); + assert!(process.wait().await.unwrap().success()); + } + + #[tokio::test] + async fn exact_owned_inert_child_is_joined_before_cleanup_returns() { + let process = child("import sys;sys.stdin.buffer.read()"); + let pid = process.id().unwrap(); + let live = Live::own(process, clock_ticks().unwrap()); + let original = live + .lifetimes + .as_ref() + .unwrap() + .as_ref() + .unwrap() + .cpu_ticks() + .unwrap(); + assert!(original < 1_000_000); + live.close().await.unwrap(); + assert!(!Path::new("/proc").join(pid.to_string()).exists()); + } + + #[tokio::test] + async fn oversized_or_uncorrelated_data_never_becomes_a_turn_boundary() { + let mut process = child("print('x'*17000)"); + let mut frames = Frames { + stream: BufReader::new(process.stdout.take().unwrap()), + pending: Vec::new(), + bytes: 0, + }; + assert!(frames.next().await.is_err()); + let _ = process.kill().await; + let _ = process.wait().await; + } + + #[tokio::test] + async fn duplicate_keys_are_rejected_before_any_value_round_trip() { + let mut process = child("print('{\"version\":0,\"version\":1}')"); + let mut frames = Frames { + stream: BufReader::new(process.stdout.take().unwrap()), + pending: Vec::new(), + bytes: 0, + }; + assert!(frames.next().await.is_err()); + assert!(process.wait().await.unwrap().success()); + } +} diff --git a/crates/volparossa/src/compute/train_cycle.rs b/crates/volparossa/src/compute/train_cycle.rs index 1bb65878d..a2bf7e05c 100644 --- a/crates/volparossa/src/compute/train_cycle.rs +++ b/crates/volparossa/src/compute/train_cycle.rs @@ -674,6 +674,7 @@ fn worker_options(args: &Options, expires: u64, time: u64) -> Result 0) .context("train_cycle_source_expired")?; Ok(super::Options { + native_backend: super::native_cpu::Options::default(), model_profile: super::ModelProfile::default(), mode: Mode::Train, runtime_root: args.runtime_root.clone(), diff --git a/crates/volparossa/src/compute/train_loop/aggregate.rs b/crates/volparossa/src/compute/train_loop/aggregate.rs index 833a8a28c..74af9c2d6 100644 --- a/crates/volparossa/src/compute/train_loop/aggregate.rs +++ b/crates/volparossa/src/compute/train_loop/aggregate.rs @@ -970,6 +970,7 @@ async fn execute_selected( .min(u64::from(args.max_seconds)), )?; let options = super::super::Options { + native_backend: crate::compute::native_cpu::Options::default(), mode: super::super::Mode::AggregateAdapter, model_profile: super::super::ModelProfile::default(), runtime_root: args.runtime_root.clone(), diff --git a/crates/volparossa/src/compute/train_loop/peer_evaluation.rs b/crates/volparossa/src/compute/train_loop/peer_evaluation.rs index f6636e153..6cab0735f 100644 --- a/crates/volparossa/src/compute/train_loop/peer_evaluation.rs +++ b/crates/volparossa/src/compute/train_loop/peer_evaluation.rs @@ -250,6 +250,7 @@ async fn execute_stage( .context("peer_comparison_source_expired")? .min(u64::from(args.max_seconds)); let options = super::super::Options { + native_backend: crate::compute::native_cpu::Options::default(), model_profile: super::super::ModelProfile::default(), mode: super::super::Mode::Infer, runtime_root: args.runtime_root.clone(), diff --git a/crates/volparossa/src/compute/train_loop/validation.rs b/crates/volparossa/src/compute/train_loop/validation.rs index beb749372..004c74ee9 100644 --- a/crates/volparossa/src/compute/train_loop/validation.rs +++ b/crates/volparossa/src/compute/train_loop/validation.rs @@ -314,6 +314,7 @@ async fn run_stage( .context("train_validation_source_expired")? .min(u64::from(args.max_seconds)); let options = super::super::Options { + native_backend: crate::compute::native_cpu::Options::default(), model_profile: super::super::ModelProfile::default(), mode: super::super::Mode::Infer, runtime_root: args.runtime_root.clone(), diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index f4cc4ea14..3bfec839e 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -12,7 +12,7 @@ detailed evidence ledger, including historical failures—not an installation gu [Original v1 scorecard](#fixed-alpha-v1-scorecard) · [Completion requirements](#definition-of-done) -Last updated: 2026-10-15 +Last updated: 2026-10-16 ## Current integration and active work @@ -33,16 +33,139 @@ The immediate milestones build on the retained candidates and evidence below: | --- | --- | --- | | Integrated cache and Weather delivery — `volparossa`, `volparossa-weather` | Preserve the real provider sequence and both joined web-client proofs. | Core PR227, PR229 and PR230, and Weather PR14–PR18 are merged. Drizz.li run `38228710535` and Overmorrow run `38241212407` pass actual acquisition, separate protected cold transfers and exact initial/refresh UI checks. Independent reconstruction, Quality and CodeQL pass. Automatic acquisition, browser-initiated cold retrieval and the complete weather service remain open. | | Native Overmorrow — `volparossa`, `volparossa-weather` | Preserve actual app-triggered core delivery and verify expiry behavior with real products. | Actual `38717988016` passes on exact core `aac5be7f` / Weather `3b90442a`: initial cold peer retrieval, two cache-backed refreshes, joined native tables, background/resume and cleanup. Source Quality `38714089083` and CodeQL `38714082172` pass. Actual expiry, physical devices, iOS and the complete service remain open. | -| Persistent private storage maintenance — `volparossa`, then Image/Cloud | Complete consumer publication, repair and restoration after provider loss. | Actual `38766434111` fails during Image FINALIZE with `stream_open / flow_acquire`; the inner cause was not retained. Source run `38799870413` passes all 167 targeted Rust cases, strict Clippy and formatting; full Quality `38800337179` also passes on consumer profile 9 source `fc75a42d`. Actual trial `38804192575` now tests these pinned Image/Cloud diagnostics; its result is outstanding. The previous Cloud-catalog failure remains unresolved; combined repair, restoration, remote cleanup and reciprocal capacity are not yet established. | +| Persistent private storage maintenance — `volparossa`, then Image/Cloud | Complete consumer publication, repair and restoration after provider loss. | Targeted source run `38799870413` passes on `0e17aff5`; full Quality `38800337179` passes on profile 9 source `fc75a42d`. Actual `38804192575` reaches Cloud FINALIZE after Image publication, then fails at flow acquisition: first `helper_kernel`, then `helper_cleanup_incomplete`. The exact kernel or cleanup suboperation is unknown. A separate listener-rejection correction is under targeted verification, not established as this failure's cause. Combined repair, restoration, remote cleanup and reciprocal capacity remain open. | | Automatic private uploads — `volparossa`, Image/Cloud | Enroll a new encrypted upload under finite service authority, then resume maintenance and restore it after provider loss. | Core PR233 adds an absolute authorization ceiling; Image PR7 forwards it without rebasing. Cloud PR14 implements finite shared admission budgets, durable reservation/enrollment, restart reconciliation and an encrypted empty catalog for a first upload. Actual OpenCloud SDK upload/list/read checks pass with a synthetic core; this does not prove peer maintenance. Join these candidates to the real upload-to-repair-to-restore trial after the existing storage milestone; its pinned consumers stay unchanged. | -| Native coding execution — `volparossa`, `volparossa-code` | Complete an actual OpenCode read, edit and independently verified test through the core. | Actual `38797816570` on core `95ef4541` / Code `ffc7c3ef` completes three function-call responses and two reads, but rejects the forwarded edit permission; no edit or independent test completes. Diagnose that permission boundary without weakening workspace approval, the normal toolset, model or limits. Separately, core `6d65bb12` passes all six native targets in both normal and sanitizer modes, including the unchanged upstream tensor test against the Q1 load correction. That source result is not a new coding trial or protected peer execution. | -| Protected remote compute — `volparossa`, then Code/Browser | Bind an approved private workload to verified protection and the confirmed key. | PR258's authenticated refusal path has actual route evidence. PR262 integrates explicit same-connection key confirmation after targeted source, full Quality and CodeQL acceptance. Possession confirmation is not private-task admission. Production authority, current vendor appraisal, approved workloads and actual protected execution remain open; suitable confidential-computing hardware is unavailable. | +| Native coding execution — `volparossa`, `volparossa-code` | Complete the post-task resource checks around the successful real read/edit/test task, then integrate the accepted native candidate. | Actual `38806073909` on Code `cce5a211` / core `6d65bb12` completes two reads, an approved edit, a test command and independent verification. The outer trial fails afterward; task memory and subsequent input/core-stop checks are absent. A bounded task-holder correction is under source review to preserve final cgroup observation. Joined core candidate `985774f9` passes its normal/sanitizer native suite, CodeQL and full Quality. The ordinary toolset, 4B/BF16 model, approvals and resource limits remain unchanged. Protected peer execution remains open. | +| Protected remote compute — `volparossa`, then Code/Browser | Join an authorized immutable guest, the resident coding worker and a guest-terminated TLS 1.3 session. | PR258's authenticated refusal path has actual route evidence; PR262 integrates explicit same-connection key confirmation. The next implementation reuses the native worker lifecycle and existing TLS stack, rather than extending the probe into task authority. Boot/runtime/model integrity, production roots and current vendor appraisal must be checked before private input; task authority needs its own finite grant. Those functions and actual protected execution remain open; suitable confidential-computing hardware is unavailable. | Detailed earlier Android and storage checkpoints remain in the [pre-merge evidence ledger](https://github.com/VOLPAROSSA/volparossa/blob/2f3fa1179373b4e71ed42715bebff9bdc9c4a3ff/docs/IMPLEMENTATION_STATUS.md#android-to-core-integration-boundary). The latest completed outcomes are summarized below. -### Latest completed integration evidence — 15 October +### Latest completed integration evidence — 15–16 October + +- **A real coding task succeeds; its outer postflight still fails.** + [Trial `38806073909`](https://github.com/VOLPAROSSA/volparossa-code/actions/runs/38806073909) + uses exact Code `cce5a211a0624fdcc05a787bd4ff31422b30ae65` and core + `6d65bb12ed7b8720d43868c9306313133a5b96ae`. Its original task receipt records + five completed core requests: four function-call responses and a final answer. + Two reads, one edit and one test command complete; both forwarded permissions + are accepted. The fixture changes, the original test remains unchanged and + independent verification passes. No canned model answers or synthetic core are used. + + The task exits successfully after 2,236,512 ms, but the enclosing guest reports + `stage_failed`. Its task-memory receipt and later `inputs_unchanged` and + `core_clean_stop` checks are absent. Source review identifies a post-exit + cgroup-observation vulnerability; the original generic exception does not + establish which read failed. The correction must preserve the original task + status and collect real counters before releasing the last task-cgroup owner, + without extending the existing deadline or treating missing values as zero. + + All five core requests confirm session cleanup. Task runtime/project cleanup, + guest process cleanup, QEMU/observer joins, scratch removal and restoration of + CI changes pass. Routes/DNS match under the existing normalization, not raw + IPv6 equality. This establishes the bounded coding task, not complete trial + acceptance, general coding quality or private peer execution. Original artifact + and log ZIP SHA-256 values are respectively + `8c31054298ef59f0a50c360d4f772afaa010b3c66144e4af85013f49d8565925` + and `5e21e3f3ec2b6310c58079e81eacb1b35a2cf06858650afa8176463196804d30`. + +- **Storage reaches Cloud publication and retains a narrower flow failure.** + [Actual `38804192575`](https://github.com/VOLPAROSSA/volparossa/actions/runs/38804192575) + fails on exact core `fc75a42d0c458472dc3aeee28311b4e9003803e9`, with unchanged + Image `9694a2b9` / Cloud `47b0f03b` consumers. Cloud FINALIZE fragment 0/copy 0 + reports `stream_open / flow_acquire`, with two acquisition rounds: first + `helper_kernel`, last `helper_cleanup_incomplete`. No specific errno or helper + suboperation is retained. A kernel-category result can also represent parent + descriptor rejection; it does not uniquely identify a failed kernel call. + + The source-bound Cloud stage establishes that Image creation, deposit, policy + and enrollment checks, and local core close were reached successfully first; + it is not a separate Image repair/restore acceptance receipt. The original base + lifecycle components still prove restart/resume, two non-consuming restores, + removal of all eleven copies and zero provider usage for that base fixture. + Consumer-local cleanup and guest-owned network cleanup pass, but remote + consumer cleanup and usage are explicitly unconfirmed. The complete consumer + maintenance trial remains failed. Original artifact and log ZIP SHA-256 values: + `7fb60d72ba2d9efc18582d259697cd9c7e46c80c8d6892119182f4fef0750114` + and `75f8be9d4e6c622801ed80d04d63a43aa0af4d70731ce7a212f97386f5c44260`. + + Separate source review identifies an availability defect: rejection of one + newly accepted stream ends the reusable exit listener. Candidate `74f0b16a` + distinguishes a closed rejected stream from a listener failure while retaining + fail-closed MPTCP validation, cancellation and failure reporting. Targeted run + [`38813474802`](https://github.com/VOLPAROSSA/volparossa/actions/runs/38813474802) + passes all 17 unique tests, including the real kernel-listener test in a + disposable network namespace, plus formatting and strict Clippy. Its final + host comparison fails: routes/DNS match, but raw firewall-dump hashes differ. + Those dumps contain volatile timestamps and traffic counters; the original + hashes cannot establish whether configuration also changed. The run remains + failed. A workflow-only correction tests narrowly normalized configuration; + it does not change the Rust candidate. The predecessor `38813245298` fails only + two formatting checks before compilation. Neither targeted run establishes the + cause of the original storage failure or complete storage acceptance. + +- **The native candidate joined to current main passes native source checks.** + [Run `38811039580`](https://github.com/VOLPAROSSA/volparossa/actions/runs/38811039580) + checks merge `985774f9fbbec377f90d986fc74ff55ea3adc244`, tree + `887840a5a8ad3fa5d2fae6259969d04d621420fb`, in draft PR212. Each of normal and + ASan/UBSan modes passes the same 98 unique Python cases without skips and all + six native targets, including the complete 35-type upstream tensor suite. + Twelve source records, eight wrappers and the actual patched CPU compile input + match Git. All four CodeQL analyses in `38811040412` pass; their build-free + analysis is not compilation evidence. Full Quality + [`38811047918`](https://github.com/VOLPAROSSA/volparossa/actions/runs/38811047918) + passes on a merge checkout with the identical tree: 3,830 unique workspace + Rust tests pass, with seven ignored. Formatter, strict Clippy, dependency and + license checks, and the non-mutating harness pass. Three live namespace proofs + and one optional CLI case explicitly skip; they are not runtime evidence. + This candidate is not merged into main. The separate original coding trial + remains pinned to `6d65bb12`. Normal and sanitizer artifact ZIP SHA-256: + `3ccc0565d589015ff0866afd569cab739fa1b2f017b16d42197a2c24661f3cbd` + and `19bad8cb5240fc35eb4193fbda9215e6c94a73b45faa731bd83a934b00b809bd`. + Original Quality log ZIP SHA-256: + `c2f28db51232f608e9531555086efdb821940c41c5b923d644e85ddb2bff7f1d`. + +- **The native edit-target correction passes source checks.** + [Code PR17](https://github.com/VOLPAROSSA/volparossa-code/pull/17) merges as + `cce5a211a0624fdcc05a787bd4ff31422b30ae65` into the existing native candidate, + not application `main`. It normalizes an edit permission only when its single + path, absolute metadata path and running tool input identify the same file + inside the selected workspace. The original approval policy and all other + tool permissions remain unchanged. Source review and a regression establish + this compatibility correction, not the unique cause of the earlier trial. + + [Source run `38805641941`](https://github.com/VOLPAROSSA/volparossa-code/actions/runs/38805641941) + passes 504 unique checks with five explicit environment-dependent skips, + including all 43 new edit-target cases. Its tested merge tree matches the + candidate merge. Original log ZIP SHA-256: + `b6a011e3ff6215019f9482e30dcae36d11677f35bf6dcb9feeeda6bb304fcdaa`. + [Actual trial `38806073909`](https://github.com/VOLPAROSSA/volparossa-code/actions/runs/38806073909) + uses that Code revision and core `6d65bb12`, including the source-verified + eight-file wrapper inventory and Q1 correction. Its bounded task success and + failed outer postflight are recorded above. Joining the core candidate to + `main` does not alter its pinned sources or turn earlier failures into successes. + +- **The native diagnostic correction passes targeted source checks, not a model trial.** + [Run `38779519182`](https://github.com/VOLPAROSSA/volparossa/actions/runs/38779519182) + checks exact core `a37f5cd0b1ec212632dcff95cac4e6da589027e9`: 33 Rust cases + pass with none ignored, alongside 32 Python collector cases, strict CLI Clippy + and formatting. The preceding `38778420599` passes the same tests and Clippy + but fails formatting; `a37f5cd0` applies only its four layout corrections. + Closed typed failures survive session cleanup without exposing private error + text or changing admission, deadlines or cleanup authority. This does not + recover the original failed execution's cause. Original artifact ZIP SHA-256: + `92c8cfa62486c9ba8b1e3892aaedde955f6052dd51198866bf618558f1719505`; + log ZIP SHA-256: + `b8860c039284dff39837f77b97ef789646dd4a06c3c985762f7492c9d07131ed`. + + Paired Code `f23f9c90abe273ce07938b9f7cd5c16409ac52ca` changes only the + core pin and its test expectation. Its [source CI `38780521264`](https://github.com/VOLPAROSSA/volparossa-code/actions/runs/38780521264) + passes 455 checks with four explicit skips; the merge checkout and candidate + have the same tree. Original log ZIP SHA-256: + `4400eec4282a7c691928281c0dfa2f85b48c9c5c053cc3a8ff15859061618d87`. + These are historical source checkpoints, not model-execution proof. The later + actual trials and current integration state are recorded separately here. - **Key confirmation is integrated; protected task execution remains open.** [PR262](https://github.com/VOLPAROSSA/volparossa/pull/262) merges as `30a40a65`. @@ -64,7 +187,7 @@ The latest completed outcomes are summarized below. `78195a02e2170f0ac0709c2f07dffef2bb8f672454df1769d56e55d562e61e93` and `7e95442ded158971e860d07e2dcd7ef90aebc223c4f65d8e23884d453ccee949`. -- **The latest coding trial reaches the edit-permission boundary.** +- **The preceding coding trial reaches the edit-permission boundary.** [Trial `38797816570`](https://github.com/VOLPAROSSA/volparossa-code/actions/runs/38797816570) fails on exact Code `ffc7c3ef88aafd8ffc2c5652dbae5cf598ef9390` and core `95ef4541e518f9f9050dedc5d24bd4176b70db71`. All three submitted core requests @@ -174,8 +297,9 @@ The latest completed outcomes are summarized below. `10f6d39bfcc3162ee64d60204941e9ca125fa46fa174ae41fc342e6ddb6dcbaf`. These source/component checks do not prove that the underlying acquisition or Cloud-catalog failures are fixed. [Actual trial `38804192575`](https://github.com/VOLPAROSSA/volparossa/actions/runs/38804192575) - has been dispatched once on the same `fc75a42d` source with the existing - `private-storage-maintenance` scenario; its acceptance remains outstanding. + ran once on the same `fc75a42d` source with the existing + `private-storage-maintenance` scenario and failed during Cloud publication, + with the original failure and cleanup boundaries recorded above. - **Same-connection key confirmation passes targeted source checks.** [Run `38781307814`](https://github.com/VOLPAROSSA/volparossa/actions/runs/38781307814) @@ -587,6 +711,514 @@ See the follow-up results in [Storage PR232](https://github.com/VOLPAROSSA/volpa [Code PR11](https://github.com/VOLPAROSSA/volparossa-code/pull/11). Starting a follow-up does not supersede the last completed outcome above. +### Explicit native CPU inference candidate + +#### Connection-bound model reuse candidate + +Earlier Code trial `38742699743` on core `3327115e` / Code `8b21775e` +completes two native reads, then reaches the third request's compute deadline. +No edit or test passes. The candidate retains one model and exact processed +prompt prefixes within a single owner connection, with the normal full toolset. +The new [versioned session contract](../crates/volparossa/src/compute/private_conversation/WIRE.md#connection-bound-native-sessions) +separates per-request file removal from final worker/private-state cleanup. +No state is shared across owners or projects; failed admitted requests are not +replayed. The 600-second request ceiling and pressure/memory guards remain; +the resident worker has a separate 2,400-second task lifetime and cumulative CPU +ceiling, with 300-second idle closure and at most 128 requests. + +Core `2439892e` passes all six normal native source targets in +[run `38759834612`](https://github.com/VOLPAROSSA/volparossa/actions/runs/38759834612). +ASan/UBSan passes the ABI, loader, BF16/F32, output-constraint and session targets, +then fails the pinned upstream all-type test at `quants.c:590` on a misaligned +load. The overall sanitizer result remains failed. The +[source-bound audit](https://github.com/VOLPAROSSA/volparossa/pull/212#issuecomment-6195934792) +verifies all seven wrapper hashes; these synthetic tests do not prove actual +model loading, KV reuse or acceleration. + +The core's 164 inert Python cases pass. Code `97dfa4a` passes 455 source checks +with four explicit skips in +[run `38759140548`](https://github.com/VOLPAROSSA/volparossa-code/actions/runs/38759140548). +Core Quality `38759968511` stops at formatting before Rust tests; `d6d7c4d3` +applies its eight exact layout corrections. This historical checkpoint is +followed by full Quality on `0de65f42`, the failed paired actual trial and the +closed-diagnostic correction in the +[latest evidence](#latest-completed-integration-evidence--15-october). +Complete coding execution and confidential peer work remain open. + +#### Source verification lifecycle candidate + +[Actual trial `38726624377`](https://github.com/VOLPAROSSA/volparossa-code/actions/runs/38726624377) +on Code `4fc36928` / core `2dd38983` remains incomplete: one completed read, +a second admitted execution cancelled by the unchanged memory guard, then five +memory-admission refusals, with no edit or test. Its last observed substage is +`tokenizer_load/complete`; no later `prompt_encode` record is retained. Runtime, +project, guest and VM cleanup are confirmed; protected peer execution and general +coding quality remain unproved. The [original audit](https://github.com/VOLPAROSSA/volparossa-code/pull/11#issuecomment-6191437311) +retains the source bindings and artifact digest. Aggregate file-cache counters do +not identify individual files or prove reclaimability. + +The new candidate retains only the three source-shard descriptors used by the +complete hash verification. After successful source, configuration and dataset +checks and an owner-control check, it attempts at most three file-bound cache +advisories before GGUF preparation. Metadata binds those descriptors; it does not +replace content hashes. Existing post-supervisor hints and all resource guards +remain unchanged. Twelve inert cases exercise the real small-file hash and +descriptor sequence, failure rejection and cleanup; they do not prove reclaimed +memory or a completed model trial. + +#### Earlier memory-admission follow-up + +[Actual Code trial `38695131367`](https://github.com/VOLPAROSSA/volparossa-code/actions/runs/38695131367) +on Code `7f451e67` / core `3630ff79` completes one native OpenCode read, then +refuses six requests with `compute_model_memory_headroom`. No edit, test or +fixture change occurs. The core peak is 8,706,998,272 bytes under an +11,811,160,064-byte limit, with zero OOM kills. The original records do not show +the admission sample or whether cache advice was invoked; they do not identify +the cause of refusal. Original artifact ZIP SHA-256: +`f194f9d173436c95818ea2bcdca2327c35bea8a2d3896c65329c563508e1d5c1`. + +The next candidate observes the original admission sample and finite cache-advice +outcomes only for explicitly diagnostic native private-conversation runs. Host, +self-cgroup and ancestor headroom remain the same fail-closed decision inputs. +Optional memory statistics are read afterward; they are neither available-memory +estimates nor proof of reclaim. No cgroup names, paths, prompts or model output +are exported. The existing cleanup, single advisory syscall, result, model and +resource limits remain unchanged. The collector keeps its default v1 shape and +adds a bounded v2 memory record list only when requested. All 31 inert collector +tests pass, including an independent rerun. These observations are not a causal +fix or completion of the coding task or protected peer execution. + +Initial hosted check `38702380046` stops at formatting, before Rust tests. Its +source-bound formatter artifact supplies five layout-only corrections in four +files; the temporary artifact step is removed after applying them. Artifact ZIP +SHA-256: `e4471084d545a26e9aa1850cee46a270cf1cbe06e93590dfb6f22201662065cb`. +No local Rust compilation or formatting runs. + +[Quality `38702928794`](https://github.com/VOLPAROSSA/volparossa/actions/runs/38702928794) +on `0091e75f` passes formatting, 60 native Python contracts and the targeted +Rust suites: 28 native/cache-hint, seven admission and seven sampler tests, +including all sixteen new cases. The collector's 31 checks pass on the hosted +runner as well. The original merge checkout has the exact candidate tree. +Strict Clippy then rejects a similar variable name and a literal `unwrap_err` +in a test; workspace tests and subsequent gates are skipped. The correction +renames only that binding and explicitly matches the expected error while +retaining the original error-text assertion. Full Quality is still required. +Original job-log ZIP SHA-256: +`47fce1d4bc03c78984c4e3e78b2a8c8cfaacb7e8e927df28203c6f35f526b748`. + +Code `8b214da1` pins `0091e75f`, opts into these closed native observations and +retains the existing 256 KiB public-export ceiling. Torch's diagnostic calls +and 64 KiB guest-receipt limit remain unchanged. Its exact-source +[CI `38703873328`](https://github.com/VOLPAROSSA/volparossa-code/actions/runs/38703873328) +passes 410 tests with four explicit skips. No new model trial has run; Code +must first pin the corrected core and pass the required source checks. + +#### Tool-call output constraint candidate + +- [ ] Continue the existing native OpenCode integration after actual Code run + `37683661957` on Code `45510055` and core `9e9086ee` produced one invalid JSON + tool-call body. That run proves no usable turn, tool action or coding result; + it does not identify a unique cause inside generation. + +The new optional `qwen_tagged_tool_json_v1` profile binds IPC negotiation, +owner-authorized backend manifest v2/build v3, the actual serialized dataset, +core-derived offered-tool aliases, worker request and returned result. The +native ABI preserves the old unselected greedy path. Selected requests allow +ordinary answers, or one tagged JSON tool call with arguments nesting at most +16. The strict final parser remains authoritative: no output repair, forced +tool use, prompt truncation, extra time or alternate model is introduced. + +171 targeted inert Python checks pass, including a serialized selected dataset +through the actual worker/encode/decode path with a synthetic native executor. +That test caught and corrected a cross-layer input-field rejection. +[Hosted Quality `38672934132`](https://github.com/VOLPAROSSA/volparossa/actions/runs/38672934132) +on exact core `9d044921` passes formatting, strict Clippy, 60 native Python +contracts and all thirteen new Rust manifest, request/result, parser and IPC +binding cases. The workspace records 3,558 distinct passing tests and seven +ignored tests. Three namespace proofs and one optional CLI identity proof are +explicitly skipped, not successful datapath evidence. Original job-log ZIP +SHA-256: `7760651e167a4700b0967c19dca7c6755b3a71e463799658faac3f372d576571`. + +Earlier Quality `38671767972` on `030c16f0` stopped at strict Clippy's +`if_same_then_else`: two adjacent identical rejection branches became one +equivalent disjunction, without suppressing the lint or changing admission. +Run `38671600105` stopped at formatting before Rust compilation; `030c16f0` +changes only those seven formatting hunks. These source checks are not proof +of a complete OpenCode coding task or protected peer execution. + +[Actual Code trial `38675278393`](https://github.com/VOLPAROSSA/volparossa-code/actions/runs/38675278393) +on Code `6aa6291a` / core `9d044921` passes native build, conversion and model +provisioning with the selected output constraint. One of seven submitted model +requests completes with a function call, and the actual OpenCode runtime +completes one `read` action. Six requests return `execution_failed`; none is +reported as invalid output or an execution-budget error. The only retained core +event is `result_observed`, which does not identify the six failures' causes. +The task ends after 588,147 ms as `opencode_task_incomplete`: no edit, test, +complete native turn or successful coding loop is proved. Its read-state +counters describe observed transitions, not three distinct tool calls. + +Reported runtime, project, provisioning-group, guest-unit/private-data, +QEMU/scratch and owned CI-host cleanup complete. Scoped normalized route/DNS +observations match; raw IPv6 route bytes differ, so this is not byte-identical +host-state evidence. Raw model output remains excluded. Original artifact +`11946278747` ZIP SHA-256: +`487bee78bb4857c6a5b48df7d69df21ceeb608ded9d9264122668480f62afa74`; +original job-log ZIP SHA-256: +`5ff8a4055bf966f192f80b366cbeb8428d77793c6ee6525b3109eecf292af36e`. + +The next diagnostic candidate observes failed private-service tasks after the +existing cleanup-slot decision, including errors before worker supervision or +after report validation. It uses the existing opt-in local diagnostic target +and an exact closed list of setup/result-binding codes. Unknown errors remain +unclassified; raw errors, paths, prompts, request identities and model output +are not exported. Wire responses, cleanup/cancellation precedence, retry policy, +model, resources and acceptance are unchanged. This is not a fix or a causal +explanation for the six observed failures. + +The core's closed collector passes 23 Python checks. Three new Rust tests cover +real pre-supervisor admission failure, report rejection, logging enabled/disabled, +privacy canaries and unchanged cleanup/cancellation behavior. All three pass in +[Quality `38678859785`](https://github.com/VOLPAROSSA/volparossa/actions/runs/38678859785) +on the direct checkout `5607f1177dc230a71414e7468c221b950f43bca6`, together with +all thirteen output-constraint Rust cases, 60 native Python contracts, formatting, +strict Clippy, the workspace (3,561 distinct passes, seven ignored) and non-mutating +harness. Skipped namespace proofs remain skips, not datapath evidence. Original +job-log ZIP SHA-256: +`60e43c4a0a63a250b4af601245d9e41772135149e47663a730a511866347d1b9`. +Earlier run `38678595442` stopped at one test assertion's formatting; `5607f117` +corrects only that layout. Its skipped Rust steps are not passing evidence. + +Code `e39267343612ed8b594d15db3e161465c25b8c2f` now pins this verified diagnostic +source without changing its model, resource or acceptance settings. The pin +change passes 103 targeted Python checks with two explicit retained-source +skips. [Code source CI `38681259931`](https://github.com/VOLPAROSSA/volparossa-code/actions/runs/38681259931) +also passes: 405 checks and four explicit skips, including a missing-runner-bwrap +synthetic namespace check. All 114 source blobs in merge checkout `3580a655` +match the candidate. Original log ZIP SHA-256: +`0b9d42a5f0526ada26cf56166b2698d48b83510a3d6ffb5d5950a113d3b9e93c`. + +The [actual follow-up `38681471469`](https://github.com/VOLPAROSSA/volparossa-code/actions/runs/38681471469) +on that Code/core pair completed with one read and six memory-admission refusals. +It preceded the cache-advice candidate and the latest trial above; neither +established a successful coding loop. + +[Source-only native run `38671551374`](https://github.com/VOLPAROSSA/volparossa/actions/runs/38671551374) +on `b8bd8228` passes the normal build and all five check targets. ABI, loader, +BF16/F32 and the new output-constraint tests also pass under ASan/UBSan. The +unchanged upstream all-type test then fails on the already recorded unaligned +Q1_0/Q8_0 load at `ggml/src/ggml-cpu/arch/x86/quants.c:590`; the full sanitizer +job remains **failed**. The selected converter and model adapter admit BF16/F32, +not this quantized kernel. This narrower evidence supports the next bounded +development trial, not a clean upstream sanitizer result or release readiness. +No model was loaded and no local Rust, native build or inference ran. + +The normal build's v3 manifest binds the six wrapper sources, exact upstream +tree and library SHA-256 +`3e59444d6f81c753ab1fcd3418ebabfb42f0368b24c76961ee59ddaf339617fa`. +Native sources, Python contracts and workflow are byte-identical between +`b8bd8228` and `9d044921`. Original normal artifact SHA-256: +`40ecaab5262d043a25b3d30d52a641f8406130103741e00d6857eca925201163`; +sanitizer artifact: +`3c948ae71d8c54870fe1a151b95d699556aa0907f9ccd17b5a9d3d18d71b5272`; +job-log ZIP: +`21b840e5ec8c39d8e7af2d9b23214b6eb1a5b04f324343fd57386a6ab8d21dde`. + +Initial source-only run `38671269423` on `4e5126ad` stops before compilation or +build-log creation in both modes. The original preflight resolves CMake to +`/usr/local/bin/cmake`, while the scrubbed build PATH excludes `/usr/local/bin`. +The generic builder failure does not retain its internal exception, so missing +CMake is a source-supported diagnosis, not an exported error code. Preflight and +build now use the same fixed hosted PATH, without installing anything. No native +check passed in this attempt. Original job-log ZIP SHA-256: +`f05e248ac6685079055b8a4db631d43b85ded5792be0d53a2374ce446c0e0177`. + +#### Earlier native candidate evidence + +- [ ] An owner-selected `llama_cpp_bf16_v1` backend is being connected to the + existing isolated private-conversation worker. The source-only build of pinned + llama.cpp `7fe450e19305b828c199d602c23a8337aaa1f03b` passes. Actual conversion + and provisioning pass. The historical unselected-output trials include + `compute_deadline` during post-generation verification and `invalid_output`; + neither proves a usable native turn or successful OpenCode coding task. The + selected-output follow-up above now proves one model-driven read action, + still not a complete coding loop. All original failed trials remain below. + This first backend accepts only the original Qwen3-4B-Instruct-2507 profile and + explicitly negotiated `greedy_v1`; public jobs, training and other profiles + retain their existing backend. It does not establish confidential peer execution. + +The fixed owned native ABI receives the complete original tokenizer-rendered +prompt in bounded prefill batches, not a shortened prompt or smaller/quantized +model. Conversion must compare every original tensor value; BF16 matrices stay +BF16, while required norm tensors are exactly promoted to F32 and reported as +`bf16_with_exact_f32_norms`. AVX2, FMA and F16C are explicit CPU requirements. +An owner-supplied manifest digest binds the native library and converted model; +peer data cannot choose executable code. Artifact verification precedes loading +inside the unchanged network-isolated sandbox. Deadlines, owner-priority controls, +cleanup confirmation and original model-result checks remain required. + +Six inert Rust authorization/result-binding checks, fifteen private-service tests +and five sandbox tests pass. They cover absent legacy fields, restricted native +admission and cancellation/cleanup contracts, not real model performance. Actual +conversion, complete inference and the unchanged native OpenCode coding scenario +must pass before this path is described as working end to end. + +The definitive normal source-only build records library SHA-256 +`4af8d584349d184049c5f6ffea3e5618839c69536676a6bb5c12ef33b03755a7` +and build-manifest SHA-256 +`d51c06798cae305622cf91089692c323cc31c1af8ff63420b270aaf44830d27c`. +Twenty-five native Python contract checks and thirteen targeted provision/retry +checks pass. They include the explicitly selected, exact SentencePiece converter +dependency without changing the original 38-wheel runtime; its native extension +adds one wheel. Real harmless subprocess tests cover timeout, cancellation, +cancellation racing a successful wait, and outer SIGKILL followed by the owned +process-group cleanup. No converter child detaches into a new group. These are +not actual model-conversion or inference tests. + +The first [candidate quality run `37233543544`](https://github.com/VOLPAROSSA/volparossa/actions/runs/37233543544) +on `d2fe6728` remains failed: strict Clippy rejected the permission-mask spelling +(`verbose_bit_mask`), after formatting and the nineteen original native Python +checks passed. The equivalent `trailing_zeros() >= 6` spelling preserves the +requirement for zero group/other permission bits. All six native Rust tests pass +again; no assertion or privacy boundary was removed. Retained decoded job +`111528060483` log SHA-256: +`80dbe57d6b939c97c41785c4b7473fd2cc218daa197f017b4157b92501399685`. +Skipped later steps are not passing workspace or datapath evidence. + +The subsequent [quality run `37235233676`](https://github.com/VOLPAROSSA/volparossa/actions/runs/37235233676) +on `f7e2c3b9` passes strict Clippy, but **fails** four full-command-tree tests: +the flattened native argument structure and its parent both use Clap's default +`Options` group name. The other 578 binary tests pass; later namespace/integration +steps are skipped. All four CodeQL analyses pass separately. Original job +`111532896805` log SHA-256: +`227fc42e300b3515506ebed4f3abab453222bb70659dadef9ef53772b9e35126`. +No native model trial was dispatched on that failed candidate. Correcting the +argument-group identity must retain the paired owner options and all existing +admission rules; source-only CLI checks are not model-execution evidence. + +The follow-up gives only the flattened native options an explicit, unique Clap +group identity. Nine targeted native tests pass, including full-command-tree +validation, both-or-neither owner flags on `run` and `private-serve`, unchanged +explicit execution, and rejection of native flags on `private-task`. The four +original failing command-tree tests also pass locally. The new command-tree test +was observed failing before the correction. These checks do not replace the +exact-source CI or a disposable native model trial. + +Core `7308371b20ced0504662178beb0e46586cfc9d2d` subsequently passed its +source checks. The first native Code trial `37239050033` stopped during source +build without retained resource/manifest evidence. Its bounded ready/release +follow-up on Code `01c5c591b16ed34226341e04fe673a35a7a169de` and the same core +reached a completed source build in +[trial37614132814](https://github.com/VOLPAROSSA/volparossa-code/actions/runs/37614132814). +The live helper's verified library and manifest were retained before successful +exit; observed build peak was 1,031,565,312 bytes, with zero swap/OOM. + +That original attempt remains **failed** at model provisioning with the closed +category `native_conversion` and process status 1. Wheel-graph/runtime-import +checks passed first, but the catch-all conversion marker does not identify which +conversion check or subprocess failed. Build resource counters do not describe +the later conversion. No model provisioning, inference or coding-task success +is claimed. Reported guest units/process-group/private-data and outer QEMU/scratch +cleanup passed; the scoped routes/DNS comparison remained unchanged. +Original artifact ZIP SHA-256: +`d4b32d66e90d4c76568b2fa989cbab5e49bbb736283868a03a81f9ae0303a254`; +job `112768290137` log SHA-256: +`6e561f75a15560a56f3d98c1a49e518c52ad543646ea1463c7f475e5ec2d0ce6`. +The follow-up retains a closed conversion stage, failure category and bounded +child exit status without exception text, paths or raw converter output. The +legacy failure marker remains. Twenty-nine native/conversion fixture tests pass, +independently repeated by root, including inert injection at all thirteen stages +and child failure, timeout and cancellation. Quality runs the new tests as part +of the existing native contract step. This is diagnostic coverage, not a model +conversion fix or permission to rerun the same failed attempt. + +The exact diagnostic follow-up on core `7739206361aa0aeb7cf0b20d9f8cf1cc9da5c81b` +and Code `61d2c0c52468eee7728cf6f32a107baca5bae240` also remains **failed**: +[trial37623495917](https://github.com/VOLPAROSSA/volparossa-code/actions/runs/37623495917) +reached verification after the converter exited with status zero. Its closed +diagnostic reports `stage=verify`, `failure=contract`; it does not identify the +first rejected assertion. No model was admitted and inference did not run. +Reported process/unit/private-data and QEMU/scratch cleanup passed; the scoped +route/DNS comparison was unchanged. Original artifact ZIP SHA-256: +`50ad3ba19fe9811e132f2dad7ee3c49407d5fcecc86a65a816d7a0637eab996e`; +job `112799334236` log SHA-256: +`24bcefeb3279b24e011008fecca2037a0808dac2af82587527c1d378759c86a3`. + +Independent source review found a verifier incompatibility: the pinned Qwen3 +converter omits `rope.dimension_count`, while its loader defaults that value to +the explicit attention key length. The follow-up checks that effective rotary +dimension, key length and value length all equal the original model's head +dimension. Wrong explicit overrides and every missing required field still fail. +Tensor values, model pins, tokenizer checks and resource limits are unchanged. +This is a source-proven correction, not proof that it was the first failing check +in the original trial or that full model conversion now succeeds. + +The following exact-source [trial 37631532611](https://github.com/VOLPAROSSA/volparossa-code/actions/runs/37631532611) +on Code `8a7034d5e09cf34f8ac67742f347eb657f769440` and core +`10fd16dc78a0c91dee627b9d8bdab2226f2cc415` **fails during execution**, +after a successful source build, conversion and model provisioning. The verified +original BF16 GGUF contains 8,051,283,488 bytes (SHA-256 +`d7934f6fa06f9781bc15540f0e2b83f89b1895e86440a4e97af4f0434311d1bd`). +The worker reaches `verify_files/begin` and then reports +`compute_control_ack_deadline`: an I/O-pressure pause was issued at 19.12, +but only the preceding resume was acknowledged. Source review found no owner +control checkpoint in the full-weight hash loop or subsequent native bundle and +post-execution verification. The follow-up processes controls on the execution +thread between the unchanged 1 MiB hash reads, including initial preparation, +native bundle validation and post-execution rehashing. Every original byte, +digest comparison, pressure threshold and deadline remains required. All 138 +focused worker/native tests pass, independently repeated by root; real files and +owner-control pipes prove pause/resume, cancellation without a partial result, +unchanged deadlines and tamper rejection. A single blocked read or other +non-cooperative operation can still exceed the existing deadline. This is a +tested control-path correction, not a successful repeat large-model trial. + +No native model turn or tool call completed. Six OpenCode requests failed within +this one workflow, all with cleanup confirmed; no workflow retry was dispatched. +Core peak memory was 2,303,844,352 bytes, with zero OOM kills and swap. Reported +unit/process/private-data/QEMU/scratch cleanup passed; scoped host-state files +match and no raw model output was exported. This is conversion/provisioning +evidence, not inference, successful coding or protected peer execution. +Original artifact ZIP SHA-256: +`78c4732774fc93dae9872df0460e2addf7f3849a6a190f803069fbc7d6a6e9c8`; +job `112826781182` log SHA-256: +`ef7d5c7bbeeebf497ec9a42881746a782b36f7c7bddc9febef11d7af373cb44f`. +All earlier failed trials and their consumed dispatch receipts remain preserved. + +The owner-responsive verification [trial 37641148934](https://github.com/VOLPAROSSA/volparossa-code/actions/runs/37641148934), +attempt 1 on Code `d48801a6` / core `773d6377`, also **fails**. Source build, +conversion and provisioning pass with the same full BF16 model. The retained +state now records all 20 issued controls acknowledged and reaches +`model_load/begin`, but ends with the closed `worker_other` diagnostic. The +receipt does not identify the native load failure's exact cause; no native turn, +tool call or coding task completes. Six failed runtime requests have confirmed +cleanup. Core cgroup peak is 8,106,295,296 bytes against 11 GiB, with zero swap +and OOM kills. Reported owned-unit, process, private-data, QEMU and scratch +cleanup passes. Normalized route/DNS observations match; raw IPv6 route bytes +differ in the excluded counter column, so byte-identical host snapshots are not +claimed for this run. No raw model output was exported. + +Original artifact `11492792961` ZIP SHA-256: +`b2621c8918f1304848fcc4a7b9e736fab2379cdf19c29de9ab11606e5c9e9cfc`; +job `112860150266` log SHA-256: +`4e32650af9e373e5e28ec93663777f1fe833feee419a5fe604fcfdfa6b7ec438`. +The consumed one-shot helper and receipts remain unchanged. Progress beyond +file verification is not successful model loading or inference. + +Source inspection found that the upstream loader starts one asynchronous +validation task per tensor, independently of the two-thread inference limit. +The new loader-only patch defers both validation paths to one execution thread +and polls the existing owner callback between tensors. It preserves all checks, +error aggregation and the final completion callback; cancellation rejects the +entire load. This removes a demonstrated source hazard, not a uniquely proved +cause of the historical failure. A single tensor check remains non-interruptible. + +The original checkout and notices remain unchanged. Build manifest v2 binds the +original tree, patch and effective loader digests and verifies the staged source +in the compilation database. Old build manifests and absent diagnostic accessors +are refused. Native errors now retain only fixed model/vocabulary/context/sampler +phase codes through the worker, core and fixture; private exception text remains +excluded. A fresh source-only build passes four check targets, including 398 tiny +F32 validations through both actual patched launch paths, invalid-tensor rejection +after complete checking, cancellation and fresh retry. Its build manifest SHA-256 +is `884bdbd00ad7cbcf1211b468d2835bf1e4b9edee2081cb6758712b3703ed0769`; +check log SHA-256 is +`adf689f10f35ebdeaf42f47d997e3496809f1683788d7dd7eacb1085622a5510`. +One hundred twenty-five focused worker tests and twenty-one diagnostic-fixture +tests pass, independently repeated by root. No patched model-loading or inference +trial had run at that source-only checkpoint, and no resource limit or task was +relaxed. The subsequent actual trial is recorded below. +The fixed-code Rust diagnostic test also passes on CI's Rust 1.85. Independent +source review and a repeat of the actual tiny validation harness found no blocker. + +Native sanitizer evidence is mixed and remains explicit: the owned ABI smoke +passes, but the unchanged upstream all-type tensor test **fails** under UBSan on +an unaligned `uint32_t` load in the Q1_0/Q8_0 dot product. That quantized path is +outside this BF16-only candidate, but the full test is not waived or relabeled. +Its original log SHA-256 is +`37e07701e4f9a7fadbee0876f77d7eb034192e951a4cd2da572606255bf84f08`. +A separate ASan/UBSan target reuses unchanged upstream F32/BF16 test functions +and tolerances; its 18 F32 vector cases and three BF16 checks pass (log SHA-256 +`6fac1d1773ae750583092ed00014e40983d319811a67afea385cc014fe514e46`). +This is scoped kernel evidence, not a passing complete upstream suite or model +inference. Sanitized build artifacts cannot be provisioned as production models. + +The subsequent [trial 37654339995](https://github.com/VOLPAROSSA/volparossa-code/actions/runs/37654339995), +attempt 1 on Code `8b5d5b6a` / core `f4cd8bd7`, remains **failed**. Native source +build, conversion and provisioning complete. One submitted model request returns +one cleanup-confirmed incomplete result (`invalid_output`); no usable turn, tool +call, permission request or completed command is observed. The provider reports +`invalid_model_output` once, with zero `execution_failed`, token-limit and +wire-truncation events. Core records `result_observed` once and +`conversation_native_json` twice: the latter are two validations of the same +report, not two executions. The tagged tool-call body failed typed JSON +deserialization, but the historical closed code does not distinguish syntax, +schema/type, duplicate fields or incomplete JSON. No raw model output was +exported, and no unique prompt, template or model-loading cause is established. + +Core cgroup peak is 4,555,878,400 bytes against 11 GiB, with zero OOM kills and +swap. Task-memory and graceful-core-stop fields are absent; they are not zero or +successful observations. Reported owned units/process-group/private data and +outer QEMU/scratch cleanup pass. Normalized route/DNS observations match, while +raw IPv6 route hashes differ; this is scoped observation, not byte-identical host +state or a complete firewall audit. Original artifact `11499080343` ZIP SHA-256: +`c7d3ed9150e8eb8e7d58a65ed46b0179c997a74f2191c621cf0109552278b6dd`; +job `112905525075` decoded log SHA-256: +`fd62b12408eee3e4bbf6c994a84a9cede4fd088806103525782cf428f1ff6610`. +The original archives, logs and consumed receipts remain unchanged. + +The diagnostic-only follow-up separates Serde's closed JSON error categories +(`syntax`, `data`, `eof`, `io`) after the unchanged report binding. Historical +generic codes remain readable. It exports no parser message, field name, source +position or output fragment and leaves `invalid_output`, single-call acceptance, +the complete prompt/model and every resource/deadline bound unchanged. This is +source-level diagnostic coverage, not a repaired model result or passing coding +trial. No new model execution had run at that checkpoint; its subsequent actual +trial is recorded below. + +The diagnostic follow-up [trial 37671030306](https://github.com/VOLPAROSSA/volparossa-code/actions/runs/37671030306), +attempt 1 on Code `2b2687fec5376b6e49f8266dff71cc7935845e99` / core +`36d616fb6d79a36fe7c8ee95c4ad84bd56b561ae`, **fails at the execution +deadline**, not at the earlier JSON rejection. Build, conversion and provisioning +complete. The worker records 44 generated tokens and an internal generation +completion at 518,788 ms, then `verify_after/begin` at 519,120 ms. Its last state +is paused under `quiet_hold`, with all 31 issued controls acknowledged and pause +as the last acknowledgement. Core reports `compute_deadline`; the provider +records one cleanup-confirmed `execution_budget_exceeded` request. Internal +generation completion is not a verified result delivered to OpenCode. + +The task ends after 603,331 ms with zero completed native turns, observed tool +calls, approved reads/edits/tests or completed commands. The fixture is unchanged +and independent verification is not reached. Core cgroup peak is 9,466,974,208 +bytes within 11 GiB, with zero OOM kills and swap. These checkpoints do not measure +total pause time or establish a unique cause of the deadline overrun. Reported +runtime/project, guest-unit/private-data, QEMU/scratch and owned CI-host cleanup +pass. Normalized route/DNS observations match, while the raw byte comparison is +false; this is not byte-identical host state or a complete firewall audit. No raw +model output was exported. + +Original artifact `11505764017` ZIP SHA-256: +`935e115a86abf1da83fe0e3d68ebac90d37ecbe399c7dcc79f055ff26dc33e83`; +job `112962546447` original raw log, 68,786 bytes, SHA-256: +`369407954fff82728d2ff41d5d0e229c78726ea8ae487bf2112964138ca492d8`. +The log is retained without decoding or newline normalization. Earlier failures, +original receipts, model/task checks and resource/deadline bounds are unchanged. + +The next source candidate makes native verification scope explicit. Native +inference reads the converted GGUF, not the original conversion-source shards. +The worker retains the actual complete initial shard/aggregate measurement and +fully verifies the used GGUF before loading and after closing the native handle. +It no longer reads the unused 8,044,982,000 source bytes a second time after +generation. The PyTorch execution-weight checks remain unchanged. + +A mandatory closed `verification_scope` in the internal native report states +this distinction; Rust rejects old reports, missing scope and extra or different +scope. Conversion tensor checks, complete GGUF hashes, model identity, owner +controls, resources and deadlines are unchanged. The focused Python and Rust +contract checks and independent source review pass. This removes redundant work, +not the failed trial: **a new complete native turn and OpenCode coding scenario +still need actual execution evidence**. See the +[verification contract](../crates/volparossa/src/compute/private_conversation/WIRE.md). + ### Bounded QEMU shutdown integrated The shared VM wrapper now limits its poweroff SSH command to 15 seconds with a diff --git a/tests/integration/agent-private-conversation.py b/tests/integration/agent-private-conversation.py index 95e93a506..79422c7f6 100644 --- a/tests/integration/agent-private-conversation.py +++ b/tests/integration/agent-private-conversation.py @@ -52,6 +52,17 @@ 'not_connected', 'probe_failed'} SERVICE_PHASES = {'execution', 'capture', 'refresh', 'lifetime', 'reap', 'storage', 'task'} SERVICE_CODES = { + 'compute_private_budget', 'compute_model_memory_headroom', 'compute_runtime_busy', 'compute_lock_invalid', + 'compute_output_create', 'compute_sandbox_spawn', 'compute_sandbox_prerequisite', 'compute_runtime_missing', + 'compute_native_backend_authorization', 'compute_native_backend_manifest_hash', + 'compute_native_backend_manifest_schema', 'compute_native_backend_identity', + 'compute_native_backend_artifacts', 'compute_native_backend_conversion', 'compute_native_backend_file', + 'compute_native_backend_scope', 'compute_native_backend_policy', 'compute_native_backend_output_constraint', + 'compute_private_result_scope', 'compute_private_result_input', 'compute_private_result_model', + 'compute_private_result_model_weights', 'compute_private_result_outputs', 'compute_private_result_output', + 'compute_native_backend_result_binding', 'compute_native_backend_result_precision', + 'compute_native_backend_output_constraint_binding', 'conversation_generation_policy_binding', + 'conversation_output_constraint_binding', 'conversation_report_binding', 'unclassified', 'worker_other', 'startup_missing_result', 'result_observed', 'panic', 'join_cancelled', 'compute_deadline', 'compute_owner_busy', 'compute_memory_budget', 'compute_storage_budget', 'compute_process_bound', 'compute_thread_bound', 'compute_process_observation', 'compute_observation_size', @@ -67,17 +78,47 @@ 'compute_stderr_size', 'compute_stderr_read', 'compute_wait', 'compute_worker_exit', 'conversation_request_id', 'conversation_native_marker', 'conversation_native_call', 'conversation_native_preface', 'conversation_native_json', 'conversation_unknown_tool', + 'conversation_native_json_syntax', 'conversation_native_json_data', + 'conversation_native_json_eof', 'conversation_native_json_io', 'conversation_custom_input', 'conversation_arguments', 'conversation_call_id', 'conversation_empty_answer', 'conversation_native_output_other', 'JOB_INPUT_NOT_FOUND', 'JOB_PATH_PERMISSION_DENIED', 'JOB_MEMORY_EXHAUSTED', 'BACKEND_IMPORT_FAILED', 'BACKEND_EXECUTION_FAILED', 'BACKEND_NOT_INSTALLED', 'BACKEND_VERSION_MISMATCH', 'CPU_BACKEND_REQUIRED', 'MODEL_PARAMETER_DTYPE_MISMATCH', 'MODEL_WEIGHTS_CHANGED_ON_DISK', 'CONVERSATION_TOKENIZER_SHAPE', 'CONVERSATION_TOKEN_LIMIT', 'RESULT_TOO_LARGE', 'JOB_CANCELLED', 'JOB_DEADLINE_EXCEEDED', + 'NATIVE_ABI_MISMATCH', 'NATIVE_BUILD_PATCH_BINDING', 'NATIVE_OPEN_DIAGNOSTIC_INVALID', + 'NATIVE_OPEN_INVALID_ARGUMENT', 'NATIVE_CPU_UNSUPPORTED', 'NATIVE_BACKEND_INITIALIZATION_FAILED', + 'NATIVE_MODEL_INITIALIZATION_FAILED', 'NATIVE_VOCABULARY_MISMATCH', 'NATIVE_CONTEXT_INITIALIZATION_FAILED', + 'NATIVE_SAMPLER_INITIALIZATION_FAILED', 'NATIVE_MODEL_LOAD_FAILED', 'NATIVE_OWNER_CONTROL_FAILED', + 'INVALID_NATIVE_CONTROL', + 'compute_native_session_child_exited', 'compute_native_session_cpu_backwards', + 'compute_native_session_cpu_bound', 'compute_native_session_cpu_budget', + 'compute_native_session_cpu_clock', 'compute_native_session_cpu_identity', + 'compute_native_session_cpu_stat', 'compute_native_session_receiver', + 'compute_native_session_report', 'compute_native_session_request_bound', + 'compute_native_session_root', 'compute_native_session_stderr', + 'compute_native_session_stderr_closed', 'compute_native_session_stdout_closed', + 'compute_native_session_turn_root', 'compute_native_session_unrequested_idle', + 'compute_native_session_unsupported', 'compute_native_session_worker', + 'compute_worker_invalid_json', 'compute_output_after_result', 'compute_result_missing', + 'NATIVE_DECODE_FAILED', 'NATIVE_SAMPLE_FAILED', 'NATIVE_SESSION_PREFIX_RESET_FAILED', + 'NATIVE_SESSION_CPU_OBSERVATION', 'NATIVE_SESSION_CPU_BOUND', 'NATIVE_SESSION_DEADLINE', + 'NATIVE_SESSION_MODEL_CHANGED', 'NATIVE_SESSION_PREFIX_BOUND', + 'INVALID_NATIVE_SESSION_FINISH', 'INVALID_NATIVE_SESSION_SEQUENCE', } SERVICE_IO_KINDS = {'none', 'not_found', 'permission_denied', 'unexpected_eof', 'broken_pipe', 'interrupted', 'invalid_data', 'other'} SERVICE_STDERR_CLASSES = {None, 'none', 'bubblewrap', 'user_namespace', 'proc_mount', 'exec_denied', 'python_startup', 'other'} +MEMORY_MARKER = b'private_memory_diagnostic ' +MEMORY_RECORD_BYTES = 4096 +MEMORY_JSON_MAX = 2**53 - 1 +MEMORY_REQUIRED_BYTES = 11274289152 +MEMORY_COUNTERS = {'headroom_bytes', 'host_available_bytes', 'self_headroom_bytes', + 'ancestor_headroom_bytes', 'self_current_bytes', 'self_max_bytes', + 'ancestor_current_bytes', 'ancestor_max_bytes'} +MEMORY_STAT_COUNTERS = {'anon_bytes', 'file_bytes', 'shmem_bytes', 'inactive_file_bytes', + 'active_file_bytes', 'kernel_bytes'} EXPORT_NAMES = {'agent-private-conversation-smoke.json', 'host-state-before.json', 'host-state-after.json', 'current-phase', 'guest-exit-status', 'runner.stdout', 'runner.stderr'} @@ -383,8 +424,111 @@ def execution_state(value): return value -def service_diagnostic(path): +def memory_snapshot(value): + """Closed, sequential observations; no reservation or cache-reclaim claim.""" + require(type(value) is dict and set(value) == MEMORY_COUNTERS | {'limiting_scope', + 'missing_stage', 'io_kind', 'numeric_overflow', 'self_stat', 'ancestor_stat'}, 'memory snapshot keys') + for key in MEMORY_COUNTERS: + require(value[key] is None or (type(value[key]) is int and 0 <= value[key] <= MEMORY_JSON_MAX), + 'memory snapshot counter') + for key, domain in ( + ('limiting_scope', {'host', 'self', 'ancestor', 'multiple', 'unknown'}), + ('missing_stage', {'none', 'meminfo', 'cgroup_path', 'max', 'current', 'controller'}), + ('io_kind', {'none', 'not_found', 'permission', 'invalid_data', 'other'})): + require(type(value[key]) is str and value[key] in domain, 'memory snapshot vocabulary') + require(type(value['numeric_overflow']) is bool, 'memory overflow flag') + for key in ('self_stat', 'ancestor_stat'): + detail = value[key] + require(type(detail) is dict and set(detail) == MEMORY_STAT_COUNTERS | {'state'} + and type(detail['state']) is str and detail['state'] in { + 'unobserved', 'observed', 'unavailable', 'invalid', 'unrepresentable'}, 'memory stat shape') + for name in MEMORY_STAT_COUNTERS: + require((type(detail[name]) is int and 0 <= detail[name] <= MEMORY_JSON_MAX) + if detail['state'] == 'observed' else detail[name] is None, 'memory stat counter') + if value['missing_stage'] != 'none': + require(value['headroom_bytes'] is None and value['limiting_scope'] == 'unknown', + 'missing memory observation') + else: + require(value['io_kind'] == 'none', 'complete memory observation IO') + if not value['numeric_overflow']: + candidates = {name: value[key] for name, key in ( + ('host', 'host_available_bytes'), ('self', 'self_headroom_bytes'), + ('ancestor', 'ancestor_headroom_bytes')) if value[key] is not None} + require('host' in candidates and value['headroom_bytes'] == min(candidates.values()), + 'memory headroom minimum') + limiting = [name for name, count in candidates.items() if count == value['headroom_bytes']] + require(value['limiting_scope'] == (limiting[0] if len(limiting) == 1 else 'multiple'), + 'memory limiting scope') + return value + + +def memory_diagnostic(value): + require(type(value) is dict and type(value.get('version')) is int and value['version'] == 1, + 'memory diagnostic version') + if value.get('phase') == 'admission': + require(set(value) == {'version', 'phase', 'required_bytes', 'admitted', 'sample'} + and type(value['required_bytes']) is int and value['required_bytes'] == MEMORY_REQUIRED_BYTES + and type(value['admitted']) is bool, 'memory admission shape') + sample = memory_snapshot(value['sample']) + if sample['headroom_bytes'] is not None: + require(value['admitted'] == (sample['headroom_bytes'] >= value['required_bytes']), + 'memory admission decision') + elif sample['missing_stage'] != 'none' or not sample['numeric_overflow']: + require(value['admitted'] is False, 'unknown memory admission') + return value + require(set(value) == {'version', 'phase', 'binding', 'outcome', 'supervisor_ok', + 'before', 'after', 'syscall_error'} and value.get('phase') == 'cache_hint', 'memory cache shape') + require(type(value['binding']) is str and value['binding'] in { + 'bound', 'not_applicable', 'root_invalid', 'open_failed', 'file_invalid', 'path_changed'} + and type(value['outcome']) is str and value['outcome'] in { + 'supervisor_error', 'not_bound', 'headroom_unknown', 'headroom_sufficient', + 'identity_changed', 'range_invalid', 'advised', 'error'} + and type(value['supervisor_ok']) is bool, 'memory cache vocabulary') + outcome = value['outcome'] + if outcome in {'supervisor_error', 'not_bound'}: + require(value['supervisor_ok'] == (outcome == 'not_bound') + and (outcome != 'not_bound' or value['binding'] != 'bound') + and value['before'] is None and value['after'] is None and value['syscall_error'] is None, + 'memory cache unattempted') + return value + require(value['supervisor_ok'] is True and value['binding'] == 'bound', 'memory cache scope') + before = memory_snapshot(value['before']) + headroom = before['headroom_bytes'] + if outcome == 'headroom_unknown': + require(headroom is None and before['missing_stage'] != 'none', 'unknown cache headroom') + elif outcome == 'headroom_sufficient': + require(before['missing_stage'] == 'none' and ((headroom is not None and headroom >= MEMORY_REQUIRED_BYTES) + or (headroom is None and before['numeric_overflow'])), 'sufficient cache headroom') + else: + require(before['missing_stage'] == 'none' and headroom is not None and headroom < MEMORY_REQUIRED_BYTES, + 'cache hint requires known low headroom') + if outcome in {'advised', 'error'}: + memory_snapshot(value['after']) + require(value['syscall_error'] is None if outcome == 'advised' else + type(value['syscall_error']) is str and value['syscall_error'] in { + 'bad_fd', 'invalid', 'unsupported', 'interrupted', 'other'}, 'cache syscall result') + else: + require(value['after'] is None and value['syscall_error'] is None, 'skipped cache hint') + return value + + +def memory_json(raw): + def unique(pairs): + value = {} + for key, item in pairs: + require(key not in value, 'duplicate memory field') + value[key] = item + return value + + def invalid_constant(_): + raise ValueError('non-finite memory number') + + return json.loads(raw, object_pairs_hook=unique, parse_constant=invalid_constant) + + +def service_diagnostic(path, *, memory_enabled=False): """Local observations only; fixed vocabulary, no raw log, prompt, path or request ID.""" + require(type(memory_enabled) is bool, 'explicit memory diagnostic opt-in') if not path.exists(): return None with os.fdopen(os.open(path, os.O_RDONLY | os.O_NOFOLLOW), 'rb') as stream: @@ -397,9 +541,23 @@ def service_diagnostic(path): if truncated: lines = lines[:-1] # Never accept a partial final record. events, states, state_seen, unknown = [], [], False, False + memory = [] marker = b'private_execution_diagnostic ' state_marker = b'private_execution_state ' for line in lines: + if memory_enabled and MEMORY_MARKER in line: + try: + require(line.count(MEMORY_MARKER) == 1, 'ambiguous memory marker') + payload = line.split(MEMORY_MARKER, 1)[1] + require(len(MEMORY_MARKER) + len(payload) + 1 <= MEMORY_RECORD_BYTES, 'memory record bound') + observation = memory_diagnostic(memory_json(payload)) + if len(memory) < 16: + memory.append(observation) + else: + truncated = True + except (ValueError, KeyError, TypeError, RecursionError): + unknown = True + continue if state_marker in line: state_seen = True try: @@ -434,6 +592,8 @@ def service_diagnostic(path): result = dict(version=1, events=events, truncated=truncated, unrecognized_record=unknown) if state_seen: result['states'] = states + if memory_enabled: + result.update(version=2, memory=memory) return result diff --git a/tests/integration/test-agent-private-conversation.py b/tests/integration/test-agent-private-conversation.py index 08e44aa80..eda1f1baa 100644 --- a/tests/integration/test-agent-private-conversation.py +++ b/tests/integration/test-agent-private-conversation.py @@ -40,6 +40,29 @@ def assistant_response(text): prompt_tokens=279, generated_tokens=19, output=dict(type='assistant', text=text)) +def memory_snapshot_fixture(): + """Synthetic finite collector input; not a kernel measurement.""" + detail = dict(state='observed', anon_bytes=1, file_bytes=2, shmem_bytes=3, + inactive_file_bytes=4, active_file_bytes=5, kernel_bytes=6) + return dict(headroom_bytes=10 * FIX['GIB'], host_available_bytes=20 * FIX['GIB'], + self_headroom_bytes=10 * FIX['GIB'], ancestor_headroom_bytes=11 * FIX['GIB'], + self_current_bytes=2 * FIX['GIB'], self_max_bytes=12 * FIX['GIB'], + ancestor_current_bytes=3 * FIX['GIB'], ancestor_max_bytes=14 * FIX['GIB'], + limiting_scope='self', missing_stage='none', io_kind='none', numeric_overflow=False, + self_stat=copy.deepcopy(detail), ancestor_stat=copy.deepcopy(detail)) + + +def memory_admission_fixture(): + return dict(version=1, phase='admission', required_bytes=FIX['MEMORY_REQUIRED_BYTES'], + admitted=False, sample=memory_snapshot_fixture()) + + +def memory_cache_fixture(outcome='advised'): + return dict(version=1, phase='cache_hint', binding='bound', outcome=outcome, + supervisor_ok=True, before=memory_snapshot_fixture(), after=memory_snapshot_fixture(), + syscall_error=None if outcome == 'advised' else 'other') + + def example_receipt(): """Deliberately fabricated validator input, never exported as runtime proof.""" provisioner = runpy.run_path(str(FIX['ML'] / 'provision.py')) @@ -83,6 +106,158 @@ def example_receipt(): class FixtureTests(unittest.TestCase): + def collect_memory(self, records, enabled=True, raw_suffix=b''): + with tempfile.TemporaryDirectory(prefix='memory-collector-', dir=HERE) as directory: + path = Path(directory) / 'private.log' + data = b''.join(b'DEBUG ' + FIX['MEMORY_MARKER'] + + (record if type(record) is bytes else json.dumps(record).encode()) + b'\n' for record in records) + path.write_bytes(data + raw_suffix) + path.chmod(0o600) + return FIX['service_diagnostic'](path, memory_enabled=enabled) + + def test_memory_requires_explicit_opt_in_and_preserves_legacy_byte_shape(self): + record = memory_admission_fixture() + expected = dict(version=1, events=[], truncated=False, unrecognized_record=False) + self.assertEqual(json.dumps(self.collect_memory([record, b'PRIVATE_CANARY'], enabled=False)), + json.dumps(expected)) + current = self.collect_memory([]) + self.assertEqual(current, dict(expected, version=2, memory=[])) + event = dict(version=1, phase='task', detail=dict(code='compute_model_memory_headroom', + io_kind='none', exit_code=None, signal=None, stderr_class=None)) + suffix = b'DEBUG private_execution_diagnostic ' + json.dumps(event).encode() + b'\n' + legacy = self.collect_memory([record], enabled=False, raw_suffix=suffix) + current = self.collect_memory([record], raw_suffix=suffix) + self.assertEqual(legacy, dict(expected, events=[event])) + self.assertEqual(current, dict(legacy, version=2, memory=[record])) + state = dict(version=1, last_phase=None, substage=None, + capacity=dict(decision='pause', constraint='memory', cpu_some_avg10=None, + io_some_avg10=None, memory_bytes=None), controls=None, peak_rss_bytes=0) + suffix += b'DEBUG private_execution_state ' + json.dumps(state).encode() + b'\n' + legacy = self.collect_memory([record], enabled=False, raw_suffix=suffix) + current = self.collect_memory([record], raw_suffix=suffix) + self.assertEqual(legacy['states'], [state]) + self.assertEqual(current, dict(legacy, version=2, memory=[record])) + for enabled in (1, 'true', None): + with self.assertRaises(ValueError): self.collect_memory([record], enabled=enabled) + + def test_memory_admission_and_cache_hint_are_observations_not_reclaim_claims(self): + records = [memory_admission_fixture(), memory_cache_fixture(), memory_cache_fixture('error')] + result = self.collect_memory(records) + self.assertEqual(result['memory'], records) + self.assertFalse(result['unrecognized_record']) + self.assertEqual(records[1]['before'], records[1]['after']) + self.assertNotIn('acceptance', json.dumps(result)) + self.assertNotIn('reclaimed', json.dumps(result)) + + def test_memory_snapshot_closed_counter_types_and_stat_states(self): + validate = FIX['memory_snapshot'] + for field in FIX['MEMORY_COUNTERS']: + for changed in (-1, True, 1.5, 2**53, float('nan'), 'PRIVATE_CANARY'): + value = memory_snapshot_fixture(); value[field] = changed + with self.subTest(field=field, changed=changed), self.assertRaises(ValueError): + validate(value) + for state in ('unobserved', 'unavailable', 'invalid', 'unrepresentable'): + value = memory_snapshot_fixture() + value['self_stat'] = dict.fromkeys(FIX['MEMORY_STAT_COUNTERS']) | {'state': state} + self.assertEqual(validate(value), value) + value['self_stat']['file_bytes'] = 1 + with self.assertRaises(ValueError): validate(value) + for key in FIX['MEMORY_STAT_COUNTERS']: + for changed in (None, True, -1, 2**53, 'PRIVATE_CANARY'): + value = memory_snapshot_fixture(); value['ancestor_stat'][key] = changed + with self.subTest(key=key, changed=changed), self.assertRaises(ValueError): validate(value) + for key, changed in [('numeric_overflow', 1), ('limiting_scope', 'PRIVATE_CANARY'), + ('missing_stage', 'PRIVATE_CANARY'), ('io_kind', 'PRIVATE_CANARY'), + ('self_stat', {'private_path': 'PRIVATE_CANARY'}), ('private_id', 'PRIVATE_CANARY')]: + value = memory_snapshot_fixture(); value[key] = changed + with self.subTest(key=key), self.assertRaises(ValueError): validate(value) + + def test_memory_minimum_ties_and_partial_observations_remain_distinct(self): + value = memory_snapshot_fixture() + value['ancestor_headroom_bytes'] = value['headroom_bytes']; value['limiting_scope'] = 'multiple' + self.assertEqual(FIX['memory_snapshot'](value), value) + for key, changed in [('headroom_bytes', value['headroom_bytes'] + 1), ('limiting_scope', 'self')]: + with self.assertRaises(ValueError): FIX['memory_snapshot'](dict(value, **{key: changed})) + partial = dict(value, headroom_bytes=None, missing_stage='current', io_kind='permission', limiting_scope='unknown') + self.assertEqual(FIX['memory_snapshot'](partial), partial) + with self.assertRaises(ValueError): FIX['memory_snapshot'](dict(partial, headroom_bytes=0)) + event = memory_admission_fixture(); event['sample'] = partial + self.assertEqual(FIX['memory_diagnostic'](event), event) + with self.assertRaises(ValueError): FIX['memory_diagnostic'](dict(event, admitted=True)) + + def test_memory_unrepresentable_headroom_does_not_rewrite_internal_admission(self): + sample = memory_snapshot_fixture() + sample.update(headroom_bytes=None, host_available_bytes=None, self_headroom_bytes=None, + ancestor_headroom_bytes=None, numeric_overflow=True) + for admitted in (False, True): + event = dict(memory_admission_fixture(), sample=sample, admitted=admitted) + self.assertEqual(FIX['memory_diagnostic'](event), event) + value = dict(memory_cache_fixture(), outcome='headroom_sufficient', before=sample, + after=None, syscall_error=None) + self.assertEqual(FIX['memory_diagnostic'](value), value) + sample.update(missing_stage='max', limiting_scope='unknown', io_kind='invalid_data') + event = dict(memory_admission_fixture(), sample=sample, admitted=True) + with self.assertRaises(ValueError): FIX['memory_diagnostic'](event) + value['outcome'] = 'headroom_unknown' + self.assertEqual(FIX['memory_diagnostic'](value), value) + + def test_memory_cache_hint_outcomes_bind_supervision_identity_and_low_headroom(self): + validate = FIX['memory_diagnostic'] + for outcome in ('identity_changed', 'range_invalid'): + event = dict(memory_cache_fixture(), outcome=outcome, after=None) + self.assertEqual(validate(event), event) + unknown = memory_snapshot_fixture() + unknown.update(headroom_bytes=None, missing_stage='meminfo', limiting_scope='unknown', io_kind='not_found') + event = dict(memory_cache_fixture(), outcome='headroom_unknown', before=unknown, after=None) + self.assertEqual(validate(event), event) + sufficient = memory_snapshot_fixture() + sufficient.update(self_headroom_bytes=12 * FIX['GIB'], headroom_bytes=11 * FIX['GIB'], limiting_scope='ancestor') + event = dict(memory_cache_fixture(), outcome='headroom_sufficient', before=sufficient, after=None) + self.assertEqual(validate(event), event) + for binding in ('not_applicable', 'root_invalid', 'open_failed', 'file_invalid', 'path_changed'): + event = dict(memory_cache_fixture(), binding=binding, outcome='not_bound', before=None, after=None) + self.assertEqual(validate(event), event) + event = dict(memory_cache_fixture(), outcome='supervisor_error', supervisor_ok=False, before=None, after=None) + self.assertEqual(validate(event), event) + for changes in ({'supervisor_ok': False}, {'binding': 'not_applicable'}, {'after': None}, + {'before': sufficient}, {'before': unknown}, {'syscall_error': 'other'}, + {'outcome': 'not_bound'}, {'outcome': 'headroom_unknown'}, {'outcome': 'PRIVATE_CANARY'}): + with self.subTest(changes=changes), self.assertRaises(ValueError): + validate(dict(memory_cache_fixture(), **changes)) + for error in ('bad_fd', 'invalid', 'unsupported', 'interrupted', 'other'): + event = dict(memory_cache_fixture('error'), syscall_error=error) + self.assertEqual(validate(event), event) + with self.assertRaises(ValueError): validate(dict(memory_cache_fixture('error'), syscall_error=None)) + + def test_memory_json_rejects_unknown_duplicate_nonfinite_and_private_values(self): + valid = memory_admission_fixture(); raw = json.dumps(valid).encode() + invalid = [dict(valid, request_id='PRIVATE_CANARY'), dict(valid, version=True), + dict(valid, required_bytes=True), dict(valid, required_bytes=1), dict(valid, admitted=True), + raw[:-1] + b', "version":1}', raw.replace(b'"numeric_overflow": false', b'"numeric_overflow": NaN'), + raw.replace(b'"anon_bytes": 1', b'"anon_bytes": 1, "anon_bytes": 2'), + b'PRIVATE_CANARY', b'[]', b'{', b'{"phase":"PRIVATE_CANARY"}', + b'[' * 1100 + b'0' + b']' * 1100, + raw + FIX['MEMORY_MARKER'] + b'PRIVATE_CANARY'] + for value in invalid: + with self.subTest(value=str(value)[:60]): + result = self.collect_memory([value]) + self.assertEqual(result['memory'], []) + self.assertTrue(result['unrecognized_record']) + self.assertNotIn('PRIVATE_CANARY', json.dumps(result)) + + def test_memory_record_and_collection_bounds_include_marker_and_newline(self): + raw = json.dumps(memory_admission_fixture()).encode() + padding = FIX['MEMORY_RECORD_BYTES'] - len(FIX['MEMORY_MARKER']) - len(raw) - 1 + accepted = self.collect_memory([raw + b' ' * padding]) + self.assertEqual(len(accepted['memory']), 1) + rejected = self.collect_memory([raw + b' ' * (padding + 1)]) + self.assertEqual(rejected['memory'], []); self.assertTrue(rejected['unrecognized_record']) + result = self.collect_memory([memory_admission_fixture()] * 17) + self.assertEqual(len(result['memory']), 16); self.assertTrue(result['truncated']) + result = self.collect_memory([memory_admission_fixture()], raw_suffix=b'PRIVATE_CANARY' * 8192) + self.assertEqual(len(result['memory']), 1); self.assertTrue(result['truncated']) + self.assertNotIn('PRIVATE_CANARY', json.dumps(result)) + def test_generation_state_closed_metadata_unknown_flags_and_forward_token_distinction(self): value = dict(prompt_tokens=12288, threads=2, interop_threads=1, cpu=dict(isa='AVX2', avx2=True, avx512_bf16=False, amx_bf16=None, amx_tile=None, @@ -221,8 +396,8 @@ def test_service_diagnostic_is_bounded_and_requires_owner_only_regular_file(self FIX['service_diagnostic'](link) def test_native_output_subtypes_are_fixed_local_observations_not_raw_errors(self): - codes = {code for code in FIX['SERVICE_CODES'] if code.startswith('conversation_')} - self.assertEqual(len(codes), 11) + codes = {code for code in FIX['SERVICE_CODES'] if code.startswith('conversation_') and not code.endswith('_binding')} + self.assertEqual(len(codes), 15) events = [dict(version=1, phase='execution', detail=dict(code=code, io_kind='none', exit_code=None, signal=None, stderr_class=None)) for code in sorted(codes)] with tempfile.TemporaryDirectory(prefix='qwen-fixture-test-', dir=HERE) as directory: @@ -237,10 +412,33 @@ def test_native_output_subtypes_are_fixed_local_observations_not_raw_errors(self self.assertFalse(result['truncated']) self.assertNotIn('PRIVATE_CANARY', json.dumps(result)) + def test_task_setup_and_report_failures_remain_closed_and_bounded(self): + source = (HERE.parents[1] / 'crates/volparossa/src/compute/supervise/diagnostic.rs').read_text() + prefix = source.split('const FIXED_CODES: &[&str] = &[', 1)[1].split('"compute_deadline"', 1)[0] + codes = re.findall(r'"([A-Za-z_]+)"', prefix) + self.assertEqual(len(codes), 30) + self.assertIn('compute_model_memory_headroom', codes) + self.assertIn('compute_private_result_scope', codes) + with tempfile.TemporaryDirectory(prefix='qwen-fixture-test-', dir=HERE) as directory: + path = Path(directory) / 'private.log' + for start in range(0, len(codes), 15): + events = [dict(version=1, phase='task', detail=dict(code=code, io_kind='none', + exit_code=None, signal=None, stderr_class=None)) for code in codes[start:start + 15]] + invalid = dict(events[0], detail=dict(events[0]['detail'], + code=events[0]['detail']['code'] + ' PRIVATE_CANARY')) + path.write_text(''.join('DEBUG private_execution_diagnostic ' + json.dumps(value) + '\n' + for value in [*events, invalid])) + path.chmod(0o600) + result = FIX['service_diagnostic'](path) + self.assertEqual(result['events'], events) + self.assertTrue(result['unrecognized_record']) + self.assertFalse(result['truncated']) + self.assertNotIn('PRIVATE_CANARY', json.dumps(result)) + def test_service_diagnostic_vocabulary_matches_source_and_log_is_not_exported(self): source = (HERE.parents[1] / 'crates/volparossa/src/compute/supervise/diagnostic.rs').read_text() expected = {'unclassified', 'worker_other', 'startup_missing_result', 'result_observed', 'panic', 'join_cancelled'} - for name in ('FIXED_CODES', 'WORKER_CODES'): + for name in ('FIXED_CODES', 'WORKER_CODES', 'SESSION_CODES', 'SESSION_WORKER_CODES'): array = source.split(f'const {name}: &[&str] = &[', 1)[1].split('];', 1)[0] expected.update(re.findall(r'"([A-Za-z_]+)"', array)) self.assertEqual(FIX['SERVICE_CODES'], expected) @@ -251,6 +449,77 @@ def test_service_diagnostic_vocabulary_matches_source_and_log_is_not_exported(se self.assertIn('--property=StandardOutput=append:{diagnostic_log}', fixture) self.assertIn('--property=StandardError=append:{diagnostic_log}', fixture) + def test_native_json_diagnostics_keep_old_code_and_only_four_closed_categories(self): + codes = {'conversation_native_json', 'conversation_native_json_syntax', 'conversation_native_json_data', + 'conversation_native_json_eof', 'conversation_native_json_io'} + self.assertEqual({code for code in FIX['SERVICE_CODES'] if code.startswith('conversation_native_json')}, codes) + events = [dict(version=1, phase='execution', detail=dict(code=code, io_kind='none', + exit_code=None, signal=None, stderr_class=None)) for code in sorted(codes)] + with tempfile.TemporaryDirectory(prefix='native-json-diagnostic-test-', dir=HERE) as directory: + path = Path(directory) / 'private.log' + unknown = dict(events[0], detail=dict(events[0]['detail'], code='conversation_native_json_PRIVATE_CANARY')) + leaked = dict(events[0], detail=dict(events[0]['detail'], message='/PRIVATE_CANARY/secret', line=2, column=4)) + path.write_text(''.join('DEBUG private_execution_diagnostic ' + json.dumps(event) + '\n' + for event in [*events, unknown, leaked])) + path.chmod(0o600) + result = FIX['service_diagnostic'](path) + self.assertEqual(result['events'], events) + self.assertTrue(result['unrecognized_record']) + self.assertFalse(result['truncated']) + self.assertNotIn('CANARY', json.dumps(result)) + self.assertNotIn('column', json.dumps(result)) + + def test_native_load_diagnostics_preserve_only_closed_phases(self): + source = (HERE.parents[1] / 'crates/volparossa/src/compute/supervise/diagnostic.rs').read_text() + historical = source.split('const WORKER_CODES: &[&str] = &[', 1)[1].split('];', 1)[0] + codes = {code for code in re.findall(r'"([A-Za-z_]+)"', historical) if 'NATIVE' in code} + self.assertEqual(len(codes), 13) + events = [dict(version=1, phase='execution', detail=dict(code=code, io_kind='none', + exit_code=None, signal=None, stderr_class=None)) for code in sorted(codes)] + with tempfile.TemporaryDirectory(prefix='native-diagnostic-test-', dir=HERE) as directory: + path = Path(directory) / 'private.log' + unknown = dict(events[0], detail=dict(events[0]['detail'], code='NATIVE_PRIVATE_CANARY')) + path.write_text(''.join('DEBUG private_execution_diagnostic ' + json.dumps(event) + '\n' + for event in [*events, unknown])) + path.chmod(0o600) + result = FIX['service_diagnostic'](path) + self.assertEqual(result['events'], events) + self.assertTrue(result['unrecognized_record']) + self.assertFalse(result['truncated']) + self.assertNotIn('CANARY', json.dumps(result)) + + def test_native_session_codes_are_closed_bounded_and_preserve_receipt_shape(self): + source = (HERE.parents[1] / 'crates/volparossa/src/compute/supervise/diagnostic.rs').read_text() + codes = [] + for name, count in [('SESSION_CODES', 21), ('SESSION_WORKER_CODES', 10)]: + array = source.split(f'const {name}: &[&str] = &[', 1)[1].split('];', 1)[0] + selected = re.findall(r'"([A-Za-z_]+)"', array) + self.assertEqual(len(selected), count) + codes.extend(selected) + self.assertEqual(len(set(codes)), 31) + with tempfile.TemporaryDirectory(prefix='native-session-diagnostic-', dir=HERE) as directory: + path = Path(directory) / 'private.log' + for start in range(0, len(codes), 15): + events = [dict(version=1, phase='task', detail=dict(code=code, io_kind='none', + exit_code=None, signal=None, stderr_class=None)) for code in codes[start:start + 15]] + invalid = dict(events[0], detail=dict(events[0]['detail'], code='NATIVE_PRIVATE_CANARY')) + raw = 'PRIVATE_CANARY /private/prompt\n' + ''.join( + 'DEBUG private_execution_diagnostic ' + json.dumps(value) + '\n' + for value in [*events, invalid]) + path.write_text(raw) + path.chmod(0o600) + result = FIX['service_diagnostic'](path) + self.assertEqual(result, dict(version=1, events=events, + truncated=False, unrecognized_record=True)) + self.assertNotIn('CANARY', json.dumps(result)) + self.assertNotIn('cleanup', json.dumps(result)) + line = 'DEBUG private_execution_diagnostic ' + json.dumps(events[0]) + '\n' + path.write_text(line * 17) + result = FIX['service_diagnostic'](path) + self.assertEqual(len(result['events']), 16) + self.assertTrue(result['truncated']) + self.assertFalse(result['unrecognized_record']) + def test_terminal_execution_state_distinguishes_gate_pause_from_completed_model_load(self): value = dict(version=1, last_phase='paused', substage=dict(stage='owner_gate', state='begin', elapsed_ms=0), capacity=dict(decision='pause', constraint='cpu', cpu_some_avg10=24.5, io_some_avg10=0.0, diff --git a/tests/integration/test-native-source-weight-cache-contract.py b/tests/integration/test-native-source-weight-cache-contract.py new file mode 100644 index 000000000..eaabd5766 --- /dev/null +++ b/tests/integration/test-native-source-weight-cache-contract.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Inert legacy-export and source-wiring checks; no Rust, model or cache syscall.""" +import json +import os +from pathlib import Path +import runpy +import tempfile +import unittest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parent.parent +LEGACY = runpy.run_path(str(HERE / 'agent-private-conversation.py')) +MARKER = b'private_source_weight_cache_diagnostic ' + + +class SourceWeightCacheContract(unittest.TestCase): + def collect(self, raw, enabled): + with tempfile.TemporaryDirectory(prefix='source-cache-contract-') as temporary: + path = Path(temporary) / 'service.log' + with path.open('xb') as stream: + stream.write(raw) + os.chmod(path, 0o600) + return LEGACY['service_diagnostic'](path, memory_enabled=enabled) + + def test_historical_export_versions_ignore_separate_source_marker_exactly(self): + event = dict(version=1, phase='task', detail=dict(code='compute_model_memory_headroom', + io_kind='none', exit_code=None, signal=None, stderr_class=None)) + original = b'private_execution_diagnostic ' + json.dumps(event).encode() + b'\n' + # Even an unknown future payload cannot contaminate the historical fields. + extra = MARKER + b'{"version":1,"phase":"source_weights","PRIVATE_CANARY":true}\n' + for enabled in (False, True): + with self.subTest(memory_enabled=enabled): + expected = self.collect(original, enabled) + self.assertEqual(self.collect(original + extra, enabled), expected) + self.assertEqual(expected['version'], 2 if enabled else 1) + self.assertFalse(expected['unrecognized_record']) + self.assertNotIn('PRIVATE_CANARY', json.dumps(expected)) + + def test_observation_target_and_marker_are_separate_from_old_export(self): + source = (ROOT / 'crates/volparossa/src/compute/native_cpu/source_weight_cache.rs').read_text() + self.assertIn('volparossa::compute::private_source_weight_cache_diagnostic', source) + self.assertIn(MARKER.decode() + '{record}', source) + self.assertNotIn('private_memory_diagnostic ', source) + self.assertNotIn('private_execution_diagnostic ', source) + + def test_existing_gguf_call_remains_before_new_advice_under_same_lease(self): + source = (ROOT / 'crates/volparossa/src/compute.rs').read_text() + order = [source.index(fragment) for fragment in ( + 'let _lease = runtime_lease', 'resources::admission(options.model_profile', + 'let source_weight_cache = ', 'let child = sandbox::command(options)', + 'let result = supervise::run(child', + 'cache_hint.after_supervisor(&result, options.model_profile, memory_diagnostic);', + 'source_weight_cache.after_supervisor(&result, memory_diagnostic);')] + self.assertEqual(order, sorted(order)) + self.assertIn('source_weight_cache.after_supervisor(&result, memory_diagnostic);\n result\n}', source) + + +if __name__ == '__main__': + unittest.main() diff --git a/workers/volparossa-ml/build_llama_cpu.py b/workers/volparossa-ml/build_llama_cpu.py new file mode 100644 index 000000000..ac1391528 --- /dev/null +++ b/workers/volparossa-ml/build_llama_cpu.py @@ -0,0 +1,355 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-only +"""Explicit source-only builder. No downloads, package installs or model execution. + +Input must be the clean original commit; outputs are always a new directory. +The resulting build.json is provenance, not independent installation authority. +""" +import argparse +import difflib +import hashlib +import json +import os +from pathlib import Path +import re +import shlex +import shutil +import signal +import stat +import subprocess +import time + +import llama_cpu as native + +SOURCE = "7fe450e19305b828c199d602c23a8337aaa1f03b" +ORIGIN = "https://github.com/ggml-org/llama.cpp.git" +KIND = "llama_cpp_bf16_v1" +HERE = Path(__file__).resolve().parent +LIBRARY = "libvolparossa_llama_cpu.so" +ALLOWED_NEEDED = {"libc.so.6", "libm.so.6", "libpthread.so.0", "libdl.so.2", "ld-linux-x86-64.so.2"} +CHECK_TARGETS = ("vp-native-abi-smoke", "vp-native-loader-validation", "vp-native-bf16-tensors", + "vp-native-output-constraint", "vp-native-session", "vp-upstream-tensor-tests") +WRAPPER_FILES = ("CMakeLists.txt", "adapter.h", "adapter.cpp", "bounded-tensor-validation.patch", + "output-constraint.h", "output-constraint.cpp", "session-state.h", "q1-aligned-load.patch") + + +def require(condition, code): + if not condition: + raise ValueError(code) + + +def digest(path): + meta = path.lstat() + require(stat.S_ISREG(meta.st_mode) and meta.st_nlink == 1, "NATIVE_REGULAR_FILE_REQUIRED") + result = hashlib.sha256() + with path.open("rb") as stream: + while block := stream.read(1024 * 1024): + result.update(block) + return {"bytes": meta.st_size, "sha256": result.hexdigest()} + + +def command(args, cwd=None, timeout=30): + return subprocess.run(args, cwd=cwd, check=True, stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=timeout).stdout + + +def verify_source(source): + require(source.is_absolute() and not source.is_symlink() and source.is_dir(), "NATIVE_SOURCE_PATH") + require(command(["git", "rev-parse", "HEAD"], source).decode().strip() == SOURCE, "NATIVE_SOURCE_PIN") + require(command(["git", "remote", "get-url", "origin"], source).decode().strip() == ORIGIN, + "NATIVE_SOURCE_ORIGIN") + require(not command(["git", "status", "--porcelain", "--untracked-files=all"], source), + "NATIVE_SOURCE_DIRTY") + tree = command(["git", "rev-parse", "HEAD^{tree}"], source).decode().strip() + require(tree == native.SOURCE_TREE, "NATIVE_SOURCE_TREE") + return tree + + +def patched_loader(source): + """Only one exact upstream file is transformed; source is never modified.""" + original = source / "src/llama-model-loader.cpp" + require(digest(original)["sha256"] == native.LOADER_ORIGINAL_SHA, "NATIVE_LOADER_ORIGINAL") + patch_path = HERE / "native-cpu/bounded-tensor-validation.patch" + require(digest(patch_path)["sha256"] == native.LOADER_PATCH_SHA, "NATIVE_LOADER_PATCH") + before = original.read_text(encoding="utf-8") + require(before.count("std::async(std::launch::async,") == 2, "NATIVE_LOADER_LAUNCH_SHAPE") + after = before.replace("std::async(std::launch::async,", "std::async(std::launch::deferred,") + old = " for (auto & future : validation_result) {\n auto result = future.get();" + new = (" for (auto & future : validation_result) {\n" + " // Complete validation stays on this execution thread; poll owner cancellation\n" + " // between tensors without exposing data or acknowledging pause while loading.\n" + " if (progress_callback && !progress_callback((float) size_done / size_data, progress_callback_user_data)) {\n" + " return false;\n }\n auto result = future.get();") + require(after.count(old) == 1, "NATIVE_LOADER_JOIN_SHAPE") + after = after.replace(old, new) + patch = "".join(difflib.unified_diff(before.splitlines(True), after.splitlines(True), + fromfile="a/src/llama-model-loader.cpp", tofile="b/src/llama-model-loader.cpp")) + require(patch.encode() == patch_path.read_bytes(), "NATIVE_LOADER_PATCH_SHAPE") + result = after.encode("utf-8") + require(hashlib.sha256(result).hexdigest() == native.LOADER_EFFECTIVE_SHA, "NATIVE_LOADER_EFFECTIVE") + return result + + +def stage_loader(source, output): + data = patched_loader(source) + overlay = output / "loader-overlay" + overlay.mkdir(mode=0o700) + target = overlay / "llama-model-loader.cpp" + with target.open("xb") as stream: + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + target.chmod(0o444) + return target + + +def verify_loader_compilation(output, loader): + path = output / "build/compile_commands.json" + require(digest(path)["bytes"] <= 16 * 1024 * 1024, "NATIVE_COMPILE_DATABASE_BOUND") + entries = json.loads(path.read_bytes()) + require(type(entries) is list and 1 <= len(entries) <= 4096, "NATIVE_COMPILE_DATABASE_SHAPE") + matches = [Path(row["file"]) for row in entries if Path(row["file"]).name == loader.name] + require(matches == [loader] and not loader.is_symlink() + and digest(loader)["sha256"] == native.LOADER_EFFECTIVE_SHA, "NATIVE_COMPILED_LOADER") + + +def patched_cpu(source): + """Copy Q1 bytes into aligned scalar storage; preserve all arithmetic.""" + original = source / native.CPU_ORIGINAL_PATH + require(digest(original)["sha256"] == native.CPU_ORIGINAL_SHA, "NATIVE_CPU_ORIGINAL") + patch_path = HERE / "native-cpu/q1-aligned-load.patch" + require(digest(patch_path)["sha256"] == native.CPU_PATCH_SHA, "NATIVE_CPU_PATCH") + before = original.read_text(encoding="utf-8") + old = " const uint32_t * GGML_RESTRICT qs32 = (const uint32_t *) x[ib].qs;\n" + new = " uint32_t qs32[4];\n memcpy(qs32, x[ib].qs, sizeof(qs32));\n" + require(before.count(old) == 1, "NATIVE_CPU_LOAD_SHAPE") + after = before.replace(old, new) + patch = "".join(difflib.unified_diff(before.splitlines(True), after.splitlines(True), + fromfile="a/" + native.CPU_ORIGINAL_PATH, tofile="b/" + native.CPU_ORIGINAL_PATH)) + require(patch.encode() == patch_path.read_bytes(), "NATIVE_CPU_PATCH_SHAPE") + result = after.encode("utf-8") + require(hashlib.sha256(result).hexdigest() == native.CPU_EFFECTIVE_SHA, "NATIVE_CPU_EFFECTIVE") + return result + + +def stage_cpu(source, output): + data = patched_cpu(source) + overlay = output / "cpu-overlay" + overlay.mkdir(mode=0o700) + target = overlay / "quants.c" + with target.open("xb") as stream: + stream.write(data) + stream.flush() + os.fsync(stream.fileno()) + target.chmod(0o444) + return target + + +def verify_cpu_compilation(output, source): + path = output / "build/compile_commands.json" + require(digest(path)["bytes"] <= 16 * 1024 * 1024, "NATIVE_COMPILE_DATABASE_BOUND") + entries = json.loads(path.read_bytes()) + require(type(entries) is list and 1 <= len(entries) <= 4096, "NATIVE_COMPILE_DATABASE_SHAPE") + staged = output / "cpu-overlay/quants.c" + files = [Path(row["file"]).resolve() if Path(row["file"]).is_absolute() + else (Path(row["directory"]) / row["file"]).resolve() for row in entries] + require(files.count(staged.resolve()) == 1 and (source / native.CPU_ORIGINAL_PATH).resolve() not in files + and not staged.is_symlink() and digest(staged)["sha256"] == native.CPU_EFFECTIVE_SHA, + "NATIVE_COMPILED_CPU") + entry = entries[files.index(staged.resolve())] + require(type(entry.get("command")) is str, "NATIVE_COMPILED_CPU_TARGET") + command = shlex.split(entry["command"]) + require(command.count("-o") == 1 and command.index("-o") + 1 < len(command), + "NATIVE_COMPILED_CPU_TARGET") + target = Path(command[command.index("-o") + 1]).parts + require(any(target[i:i + 2] == ("CMakeFiles", "ggml-cpu.dir") for i in range(len(target) - 1)), + "NATIVE_COMPILED_CPU_TARGET") + require(command.count("-c") == 1 and command.index("-c") + 1 < len(command), + "NATIVE_COMPILED_CPU_COMMAND") + compiled = Path(command[command.index("-c") + 1]) + require(Path(entry["directory"]).is_absolute(), "NATIVE_COMPILED_CPU_COMMAND") + if not compiled.is_absolute(): + compiled = Path(entry["directory"]) / compiled + require(compiled.resolve() == staged.resolve(), "NATIVE_COMPILED_CPU_COMMAND") + + +def stage_loader_checks(loader): + """Source-derived toy check: actual launch/join statements, never a model.""" + require(digest(loader)["sha256"] == native.LOADER_EFFECTIVE_SHA, "NATIVE_LOADER_EFFECTIVE") + source = loader.read_text(encoding="utf-8") + launches = re.findall(r"validation_result\.emplace_back\(std::async\(std::launch::deferred,[\s\S]*?\}\)\);", source) + require(len(launches) == 2, "NATIVE_LOADER_CHECK_SHAPE") + start = source.index(" // check validation results\n") + end = source.index(" // check if this is the last call and do final cleanup\n", start) + fragments = dict(mapped=launches[0], host=launches[1], join=source[start:end]) + for name, fragment in fragments.items(): + path = loader.parent / ("validation-" + name + ".inc") + with path.open("x", encoding="utf-8") as stream: + stream.write(fragment + "\n") + path.chmod(0o444) + + +def dependencies(raw): + require(not re.search(r"\((?:RPATH|RUNPATH)\)", raw), "NATIVE_DYNAMIC_SEARCH_PATH") + names = re.findall(r"\(NEEDED\).*?\[([^\]]+)\]", raw) + require(names and len(names) == len(set(names)) and set(names) <= ALLOWED_NEEDED, + "NATIVE_UNAPPROVED_DYNAMIC_DEPENDENCY") + return sorted(names) + + +def write_json(path, value): + with path.open("x", encoding="ascii") as stream: + json.dump(value, stream, ensure_ascii=True, sort_keys=True, separators=(",", ":")) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + + +def run_build(args, log, env, timeout): + process = subprocess.Popen(["nice", "-n", "19", *args], stdin=subprocess.DEVNULL, + stdout=log, stderr=subprocess.STDOUT, env=env, start_new_session=True) + try: + require(process.wait(timeout=timeout) == 0, "NATIVE_SOURCE_BUILD_FAILED") + finally: + if process.poll() is None: + os.killpg(process.pid, signal.SIGTERM) + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + os.killpg(process.pid, signal.SIGKILL) + process.wait(timeout=5) + + +def build(source, output, execute=False, checks=False, sanitize=False): + tree = verify_source(source) + require(output.is_absolute() and not output.exists() and not output.is_symlink(), "NATIVE_FRESH_OUTPUT") + require(not output.is_relative_to(source) and not source.is_relative_to(output), "NATIVE_SOURCE_OUTPUT_OVERLAP") + for tool in ("cmake", "c++", "cc", "readelf", "nice"): + require(shutil.which(tool), "NATIVE_BUILD_TOOL_MISSING") + require(not sanitize or checks, "NATIVE_SANITIZER_REQUIRES_CHECKS") + patched_loader(source) + patched_cpu(source) + plan = {"kind": KIND, "source_commit": SOURCE, "source_tree": tree, + "output_constraints": [native.OUTPUT_CONSTRAINT], + "native_sessions": [native.NATIVE_SESSION], + "sanitizers": sanitize, "provisionable": not sanitize, + "source_overlay": native.loader_provenance(), + "cpu_source_overlay": native.cpu_provenance(), + "jobs": 2, "models_loaded": False, "downloads": False, "install": False} + if not execute: + return plan + wrapper_sources = {name: digest(HERE / "native-cpu" / name) for name in WRAPPER_FILES} + output.mkdir(mode=0o700) + loader = stage_loader(source, output) + cpu = stage_cpu(source, output) + if checks: + stage_loader_checks(loader) + started = time.monotonic() + env = dict(os.environ) + # Avoid inherited compiler/linker/pkgconfig overrides or arbitrary preload code. + for key in list(env): + if key.startswith(("LD_", "CMAKE_")) or key in ("CC", "CXX", "CFLAGS", "CXXFLAGS", "LDFLAGS", "CPATH", "LIBRARY_PATH", "PKG_CONFIG_PATH"): + env.pop(key) + epoch = command(["git", "show", "-s", "--format=%ct", SOURCE], source).decode().strip() + require(re.fullmatch(r"[0-9]{10}", epoch), "NATIVE_SOURCE_TIMESTAMP") + env.update(SOURCE_DATE_EPOCH=epoch, CMAKE_BUILD_PARALLEL_LEVEL="2") + commands = [ + ["cmake", "-S", str(HERE / "native-cpu"), "-B", str(output / "build"), + "-DVP_LLAMA_SOURCE=" + str(source), "-DCMAKE_BUILD_TYPE=Release", + "-DVP_LLAMA_MODEL_LOADER=" + str(loader), "-DCMAKE_EXPORT_COMPILE_COMMANDS=ON", + "-DVP_LLAMA_CPU_QUANTS=" + str(cpu), + "-DVP_NATIVE_TESTS=" + ("ON" if checks else "OFF"), + "-DVP_NATIVE_SANITIZE=" + ("ON" if sanitize else "OFF")], + ["cmake", "--build", str(output / "build"), "--target", "volparossa_llama_cpu", + *(CHECK_TARGETS if checks else []), "--parallel", "2"], + ] + with (output / "build.log").open("xb") as log: + for args in commands: + remaining = 1800 - (time.monotonic() - started) + require(remaining > 0, "NATIVE_BUILD_DEADLINE") + run_build(args, log, env, remaining) + require(verify_source(source) == tree, "NATIVE_SOURCE_CHANGED") + require(wrapper_sources == {name: digest(HERE / "native-cpu" / name) for name in WRAPPER_FILES}, + "NATIVE_WRAPPER_CHANGED_DURING_BUILD") + verify_loader_compilation(output, loader) + verify_cpu_compilation(output, source) + candidates = list((output / "build").rglob(LIBRARY)) + require(len(candidates) == 1, "NATIVE_LIBRARY_OUTPUT") + library = output / LIBRARY + shutil.copyfile(candidates[0], library) + library.chmod(0o444) + dynamic = command(["readelf", "--dynamic", str(library)]).decode("ascii") + if sanitize: + require(not re.search(r"\((?:RPATH|RUNPATH)\)", dynamic), "NATIVE_DYNAMIC_SEARCH_PATH") + needed = sorted(re.findall(r"\(NEEDED\).*?\[([^\]]+)\]", dynamic)) + require(set(needed) <= ALLOWED_NEEDED | {"libasan.so.8", "libubsan.so.1"}, "NATIVE_SANITIZER_DEPENDENCIES") + else: + needed = dependencies(dynamic) + if checks: + check_env = dict(env, ASAN_OPTIONS="detect_leaks=1:halt_on_error=1", UBSAN_OPTIONS="halt_on_error=1") + with (output / "checks.log").open("xb") as log: + for target in CHECK_TARGETS: + matches = list((output / "build").rglob(target)) + require(len(matches) == 1 and matches[0].is_file(), "NATIVE_CHECK_TARGET") + log.write(("NATIVE_CHECK_BEGIN " + target + "\n").encode("ascii")) + log.flush() + run_build([str(matches[0])], log, check_env, min(120, 1800 - (time.monotonic() - started))) + log.write(("NATIVE_CHECK_PASS " + target + "\n").encode("ascii")) + log.flush() + licenses = output / "licenses" + licenses.mkdir(mode=0o700) + # Preserve all tracked notices, not only the top-level MIT declaration. + tracked = command(["git", "ls-files", "-z"], source).decode().split("\0") + for name in tracked: + if name and ("LICENSE" in Path(name).name.upper() or "COPYING" in Path(name).name.upper() + or "NOTICE" in Path(name).name.upper()): + original = source / name + if original.is_file() and not original.is_symlink(): + target = licenses / name + target.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + shutil.copyfile(original, target) + target.chmod(0o444) + runtime_archives = {} + for compiler, name in (("c++", "libstdc++.a"), ("cc", "libgcc.a"), ("cc", "libgcc_eh.a")): + path = Path(command([compiler, "-print-file-name=" + name]).decode().strip()).resolve(strict=True) + runtime_archives[name] = digest(path) + compiler_major = command(["c++", "-dumpversion"]).decode().strip() + require(re.fullmatch(r"[0-9]{1,2}", compiler_major), "NATIVE_TOOLCHAIN_NOTICE") + runtime_notice = Path("/usr/share/doc") / ("gcc-" + compiler_major + "-base") / "copyright" + require(runtime_notice.is_file(), "NATIVE_TOOLCHAIN_NOTICE") + shutil.copyfile(runtime_notice, licenses / "GCC-runtime-copyright") + (licenses / "GCC-runtime-copyright").chmod(0o444) + report = dict(plan, version=4, abi_version=1, library={"path": LIBRARY, **digest(library)}, + upstream_original_unchanged=True, loader_compile_source_verified=True, + cpu_compile_source_verified=True, + dynamic_dependencies=needed, cpu="avx2_fma_f16c", quantization=False, + checks=list(CHECK_TARGETS) if checks else [], + historical_unpatched_sanitizer_failure={ + "scope": "all_type_synthetic_tensor_test", "kind": "ubsan_misaligned_load", + "source": "ggml/src/ggml-cpu/arch/x86/quants.c:590", + "function": "ggml_vec_dot_q1_0_q8_0", "original_log_bytes": 412, + "original_log_sha256": "37e07701e4f9a7fadbee0876f77d7eb034192e951a4cd2da572606255bf84f08"}, + static_runtime_archives=runtime_archives, runtime_notice=digest(runtime_notice), + wrapper_sources=wrapper_sources, + tool_versions={tool: command([tool, "--version"]).decode().splitlines()[0] + for tool in ("cmake", "c++", "cc")}) + write_json(output / "build.json", report) + return report + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", type=Path, required=True) + parser.add_argument("--output", type=Path, required=True) + parser.add_argument("--execute", action="store_true") + parser.add_argument("--checks", action="store_true") + parser.add_argument("--sanitize", action="store_true") + args = parser.parse_args() + try: + print(json.dumps(build(args.source, args.output, args.execute, args.checks, args.sanitize), sort_keys=True)) + except (ValueError, OSError, subprocess.SubprocessError): + parser.exit(1, "NATIVE_CPU_BUILD_FAILED\n") + + +if __name__ == "__main__": + main() diff --git a/workers/volparossa-ml/conversation.py b/workers/volparossa-ml/conversation.py index 75cfbd426..f6b63ad38 100644 --- a/workers/volparossa-ml/conversation.py +++ b/workers/volparossa-ml/conversation.py @@ -16,6 +16,7 @@ QWEN = "qwen3-0.6b-v1" QWEN4B = "qwen3-4b-instruct-2507-v1" NATIVE_PROFILES = (QWEN, QWEN4B) +OUTPUT_CONSTRAINT = "qwen_tagged_tool_json_v1" def bounds(profile_name=None): @@ -69,6 +70,17 @@ def generation_policy(value, profile_name=None): return policy +def output_constraint(value, profile_name=None): + if "output_constraint" not in value: + return None + constraint = value["output_constraint"] + require(profile_name == QWEN4B and type(constraint) is str and constraint == OUTPUT_CONSTRAINT + and generation_policy(value, profile_name) == "greedy_v1" + and type(value.get("tools")) is list and 1 <= len(value["tools"]) <= 32, + "CONVERSATION_OUTPUT_CONSTRAINT") + return constraint + + def validate_call(value, tools, seen): custom = value["type"] == "custom_tool_call" fields(value, ("type", "call_id", "name", "input" if custom else "arguments"), ("namespace",)) @@ -81,8 +93,10 @@ def validate_call(value, tools, seen): def validate(value, profile_name=None): maximum, instructions, history, tools, message, description = bounds(profile_name) - fields(value, ("version", "visibility", "instructions", "history", "tools"), ("generation_policy",)) + fields(value, ("version", "visibility", "instructions", "history", "tools"), + ("generation_policy", "output_constraint")) generation_policy(value, profile_name) + output_constraint(value, profile_name) require(type(value["version"]) is int and value["version"] == 1 and value["visibility"] == "private_local", "CONVERSATION_SCOPE") require(text(value["instructions"], instructions) and type(value["history"]) is list @@ -194,9 +208,27 @@ def invalid_constant(_value): raise ValueError("CONVERSATION_JSON_CONSTANT") +def constrained_argument_depth(value): + # Count argument containers only: the arguments object is depth one; + # the tagged tool envelope and scalar/string contents do not add depth. + pending = [(value, 1)] + while pending: + item, depth = pending.pop() + if type(item) in (dict, list): + if depth > 16: + return False + pending.extend((child, depth + 1) for child in + (item.values() if type(item) is dict else item)) + return True + + def decode(value, output, profile_name=None, request_id=None): if profile_name in NATIVE_PROFILES: - return native().decode(value, output, request_id) + result = native().decode(value, output, request_id) + if "output_constraint" in value and result["type"] == "function_call" \ + and not constrained_argument_depth(result["arguments"]): + return {"type": "incomplete", "reason": "invalid_output"} + return result if output["text_truncated"]: return {"type": "incomplete", "reason": "wire_truncated"} if output["generation"]["stop_reason"] != "eos": diff --git a/workers/volparossa-ml/convert_llama_cpu.py b/workers/volparossa-ml/convert_llama_cpu.py new file mode 100644 index 000000000..669aefcb1 --- /dev/null +++ b/workers/volparossa-ml/convert_llama_cpu.py @@ -0,0 +1,414 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-only +"""Offline, disposable-guest BF16 conversion with full original-value comparison. + +No model inference, network fetch, quantization, renamed weights digest or host +installation. The original pinned runtime, source checkout and source-built +adapter must already exist. A fresh owner-authorized artifact is produced only +after every original tensor and the tokenizer/template binding have been checked. +""" +import argparse +import hashlib +import importlib.metadata +import json +import math +import os +from pathlib import Path +import re +import shutil +import signal +import struct +import subprocess +import sys +import time + +import build_llama_cpu as builder +import llama_cpu as native +import provision + +HERE = Path(__file__).resolve().parent +DIAGNOSTIC_PREFIX = "NATIVE_CONVERSION_DIAGNOSTIC " +DIAGNOSTIC_STAGES = frozenset({"guard", "source", "runtime", "model", "build", "budget", + "convert", "tokenizer", "verify", "recheck", "bundle", "manifest", "permissions", "unknown"}) + + +def child_status(value): + return value if type(value) is int and -128 <= value <= 255 else None + + +def failure_diagnostic(progress, error): + """Closed metadata only; exception text, paths and converter output stay private.""" + if isinstance(error, ConversionCancelled): + category = "cancelled" + elif isinstance(error, (TimeoutError, subprocess.TimeoutExpired)): + category = "timeout" + elif isinstance(error, FileNotFoundError): + category = "missing_file" + elif isinstance(error, PermissionError): + category = "permission" + elif isinstance(error, MemoryError): + category = "memory" + elif isinstance(error, ImportError): + category = "import" + elif isinstance(error, subprocess.SubprocessError): + category = "subprocess" + elif isinstance(error, OSError): + category = "os_error" + elif isinstance(error, (KeyError, TypeError, AttributeError)): + category = "invalid_shape" + elif isinstance(error, (ValueError, provision.ProvisionError)): + category = {"NATIVE_CONVERSION_DEADLINE": "timeout", + "NATIVE_CONVERSION_PROCESS_FAILED": "child_failed", + "NATIVE_CONVERSION_DISK_BUDGET": "disk_budget"}.get(str(error), "contract") + else: + category = "unknown" + return dict(version=1, kind="native-conversion-failure", + stage=progress.get("stage") if type(progress.get("stage")) is str + and progress.get("stage") in DIAGNOSTIC_STAGES else "unknown", + failure=category, child_exit_status=child_status(progress.get("child_exit_status"))) + + +class ConversionCancelled(Exception): + """A fixed cancellation reason; no private subprocess command is exported.""" + + +def run_converter(command, log, environment, timeout, progress=None): + """Join our direct child; never detach it from the owner's provision group. + + TERM/INT/HUP mark cancellation instead of interrupting Popen construction. + Cleanup signals only this known child, never the shared parent group. If an + outside timeout SIGKILLs this wrapper, the existing fixture's process-group + join still includes the child. The pinned converter starts no subprocesses; + native thread cleanup is part of joining its one Python process. + """ + builder.require(timeout > 0, "NATIVE_CONVERSION_DEADLINE") + deadline, process, cancelled = time.monotonic() + timeout, None, None + previous = {} + def stop(signum, _frame): + nonlocal cancelled + cancelled = signum + try: + for signum in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP): + previous[signum] = signal.signal(signum, stop) + if cancelled is not None: + raise ConversionCancelled("NATIVE_CONVERSION_CANCELLED") + process = subprocess.Popen(["nice", "-n", "19", *command], stdin=subprocess.DEVNULL, + stdout=log, stderr=subprocess.STDOUT, env=environment, + start_new_session=False) + builder.require(os.getpgid(process.pid) == os.getpgrp(), "NATIVE_CONVERTER_GROUP") + while True: + if cancelled is not None: + raise ConversionCancelled("NATIVE_CONVERSION_CANCELLED") + remaining = deadline - time.monotonic() + if remaining <= 0: + raise subprocess.TimeoutExpired("native converter", timeout) + try: + result = process.wait(timeout=min(.1, remaining)) + if progress is not None: + progress["child_exit_status"] = child_status(result) + if cancelled is not None: + raise ConversionCancelled("NATIVE_CONVERSION_CANCELLED") + builder.require(result == 0, "NATIVE_CONVERSION_PROCESS_FAILED") + return + except subprocess.TimeoutExpired: + pass + finally: + try: + if process is not None and process.poll() is None: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + finally: + if progress is not None and process is not None: + progress["child_exit_status"] = child_status(process.poll()) + for signum, handler in previous.items(): + signal.signal(signum, handler) + if cancelled is not None: + raise ConversionCancelled("NATIVE_CONVERSION_CANCELLED") + + +def tensor_name(name): + exact = {"model.embed_tokens.weight": "token_embd.weight", "model.norm.weight": "output_norm.weight"} + if name in exact: + return exact[name] + match = re.fullmatch(r"model\.layers\.([0-9]+)\.(.+)\.weight", name) + builder.require(match is not None and 0 <= int(match[1]) < 36, "NATIVE_TENSOR_NAME") + suffixes = {"input_layernorm": "attn_norm", "post_attention_layernorm": "ffn_norm", + "self_attn.q_proj": "attn_q", "self_attn.k_proj": "attn_k", "self_attn.v_proj": "attn_v", + "self_attn.o_proj": "attn_output", "self_attn.q_norm": "attn_q_norm", + "self_attn.k_norm": "attn_k_norm", "mlp.gate_proj": "ffn_gate", + "mlp.up_proj": "ffn_up", "mlp.down_proj": "ffn_down"} + builder.require(match[2] in suffixes, "NATIVE_TENSOR_NAME") + return f"blk.{int(match[1])}.{suffixes[match[2]]}.weight" + + +def header(path): + with path.open("rb") as stream: + raw = stream.read(8) + builder.require(len(raw) == 8, "NATIVE_SAFETENSORS_HEADER") + length = struct.unpack("= args.budget_bytes, "NATIVE_CONVERSION_DISK_BUDGET") + root.mkdir(mode=0o700) + output = root / "model.gguf" + env = dict(os.environ, HF_HUB_OFFLINE="1", TRANSFORMERS_OFFLINE="1", HF_DATASETS_OFFLINE="1", + HF_HUB_DISABLE_TELEMETRY="1", OMP_NUM_THREADS="2", MKL_NUM_THREADS="2", + TOKENIZERS_PARALLELISM="false") + for key in list(env): + if key.startswith("LD_") or key in ("PYTHONPATH", "PYTHONHOME"): + env.pop(key) + progress["stage"] = "convert" + with (root / "conversion.log").open("xb") as log: + run_converter([sys.executable, str(args.source / "convert_hf_to_gguf.py"), str(model), + "--outfile", str(output), "--outtype", "bf16"], log, env, deadline - time.monotonic(), progress) + check() + progress["stage"] = "tokenizer" + sys.path.insert(0, str(args.source / "gguf-py")) + import gguf + from transformers import AutoTokenizer + tokenizer = AutoTokenizer.from_pretrained(str(model), local_files_only=True, trust_remote_code=False, use_fast=True) + progress["stage"] = "verify" + verified = verify_conversion(gguf.GGUFReader(str(output)), model, tokenizer, check) + check() + progress["stage"] = "recheck" + provision.verify_weight_set(pins, model, deadline) + progress["stage"] = "bundle" + shutil.copyfile(library, root / native.LIBRARY) + shutil.copyfile(build_root / "build.json", root / "build.json") + shutil.copytree(build_root / "licenses", root / "licenses", symlinks=False) + shutil.copyfile(model / "LICENSE", root / "licenses" / "Qwen-APACHE-2.0.txt") + for notice in provision.native_converter_pins()["notices"]: + shutil.copyfile(HERE / notice["path"], root / "licenses" / Path(notice["path"]).name) + progress["stage"] = "manifest" + manifest = {"version": 1, "kind": native.KIND, "abi_version": 1, "source_commit": builder.SOURCE, + "model_profile": native.PROFILE, "source_weights_sha256": native.WEIGHTS_SHA, + "source_weights_bytes": native.WEIGHTS_BYTES, + "library": {"path": native.LIBRARY, **builder.digest(root / native.LIBRARY)}, + "gguf": {"path": "model.gguf", **builder.digest(output)}, "verification": verified, + "build_manifest_sha256": hashlib.sha256(build_raw).hexdigest()} + if build["version"] in (3, 4): + manifest.update(version=2, output_constraints=list(build["output_constraints"])) + if build["version"] == 4: + manifest.update(version=3, native_sessions=list(build["native_sessions"])) + native.validate_manifest(manifest, builder.require) + retained = sum(path.stat().st_size for path in root.rglob("*") if path.is_file()) + builder.require(retained + 65536 <= args.budget_bytes, "NATIVE_CONVERSION_DISK_BUDGET") + check() + builder.write_json(root / "backend.json", manifest) + progress["stage"] = "permissions" + private_bundle_modes(root) + return {"version": 1, "kind": native.KIND, "manifest": builder.digest(root / "backend.json"), + "model_inference": False, "downloads": False} + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--source", type=Path, required=True) + parser.add_argument("--build", type=Path, required=True) + parser.add_argument("--model", type=Path, required=True) + parser.add_argument("--root", required=True) + parser.add_argument("--budget-bytes", type=int, required=True) + parser.add_argument("--timeout-seconds", type=int, default=1800) + parser.add_argument("--execute", action="store_true") + parser.add_argument("--yes", action="store_true") + parser.add_argument("--disposable-guest", action="store_true") + args = parser.parse_args() + if not args.execute: + print(json.dumps({"kind": native.KIND, "source_commit": builder.SOURCE, "model_profile": native.PROFILE, + "execute": False, "downloads": False, "model_inference": False})) + return + progress = {"stage": "guard", "child_exit_status": None} + try: + print(json.dumps(execute(args, progress), sort_keys=True)) + except Exception as error: + print(DIAGNOSTIC_PREFIX + json.dumps(failure_diagnostic(progress, error), sort_keys=True), + file=sys.stderr, flush=True) + parser.exit(1, "NATIVE_CONVERSION_FAILED\n") + + +if __name__ == "__main__": + main() diff --git a/workers/volparossa-ml/llama_cpu.py b/workers/volparossa-ml/llama_cpu.py new file mode 100644 index 000000000..c2c063ce3 --- /dev/null +++ b/workers/volparossa-ml/llama_cpu.py @@ -0,0 +1,536 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Opt-in, core-owned BF16 CPU inference; no HTTP, downloads, tools or training. + +ctypes sees only our scalar/opaque ABI. Upstream structs stay inside the compiled +adapter. Artifact hashes are checked against the owner's separately supplied +backend.json digest before loading any native code. Conversion metadata is not +peer executable authority. The supervisor still supplies read-only mounts and +hard limits, and confirms process cleanup independently. +""" +import ctypes +import hashlib +import json +from pathlib import Path +import re +import stat +import threading + +KIND = "llama_cpp_bf16_v1" +SOURCE = "7fe450e19305b828c199d602c23a8337aaa1f03b" +SOURCE_TREE = "fd570ef54b10ec5fecb739e7e04c8ce44a6f415a" +LOADER_ORIGINAL_SHA = "efdb5f273bd1ab77301f2cadea1a5a804bafb28040aca033e45605a97a450242" +LOADER_EFFECTIVE_SHA = "fcd26ea4ae21ea04954b275287c41a35e383015b736bbbe2e68f2ab5f6d095f9" +LOADER_PATCH_SHA = "388fff7d5d9ca812abfeb27fddd1629144b7de6284e4bf685fbc8c147891e8c1" +CPU_ORIGINAL_PATH = "ggml/src/ggml-cpu/arch/x86/quants.c" +CPU_ORIGINAL_SHA = "3c489fdc77e3ab484a5f188bff9c60c0e9483c951ed65c1a562a04c249d5628e" +CPU_EFFECTIVE_SHA = "b9a63f032ad048a688357194fa535d852959ffee0942c93d4b8fb9544febf6d2" +CPU_PATCH_SHA = "563a178667eeac17e7072c343d1d21a3f641d78c284acbeb2633db26e1c36369" +PROFILE = "qwen3-4b-instruct-2507-v1" +LIBRARY = "libvolparossa_llama_cpu.so" +WEIGHTS_SHA = "79f6bbc34572c0063d12022f0f93074d90bbcd5dfd82134423bf892f7f8df3cf" +WEIGHTS_BYTES = 8044982000 +HEX = re.compile(r"[0-9a-f]{64}\Z") +ABORT = ctypes.CFUNCTYPE(ctypes.c_int32, ctypes.c_void_p) +OUTPUT_CONSTRAINT = "qwen_tagged_tool_json_v1" +NATIVE_SESSION = "connection_bound_v1" + + +def validate_constraints(value, legacy_version, require, version_error="NATIVE_OUTPUT_CONSTRAINT_VERSION"): + """Versioned capability, not a request to enable it or executable grammar.""" + version = value.get("version") if type(value) is dict else None + require(type(version) is int and version in (legacy_version, legacy_version + 1, legacy_version + 2), + version_error) + if version == legacy_version + 2: + require(value.get("native_sessions") == [NATIVE_SESSION], "NATIVE_SESSION_BINDING") + else: + require("native_sessions" not in value, "NATIVE_SESSION_BINDING") + if version == legacy_version: + require("output_constraints" not in value, "NATIVE_OUTPUT_CONSTRAINT_BINDING") + return [] + require(value.get("output_constraints") == [OUTPUT_CONSTRAINT], "NATIVE_OUTPUT_CONSTRAINT_BINDING") + return [OUTPUT_CONSTRAINT] + + +def verification_scope(): + return dict(version=1, source_weights="initial_complete_bytes_only", + execution_weights="gguf_complete_bytes_before_and_after") + + +def loader_provenance(): + return dict(kind="serial_complete_tensor_validation_v1", original_path="src/llama-model-loader.cpp", + original_sha256=LOADER_ORIGINAL_SHA, effective_sha256=LOADER_EFFECTIVE_SHA, + patch_path="native-cpu/bounded-tensor-validation.patch", patch_sha256=LOADER_PATCH_SHA, + compiled_source="loader-overlay/llama-model-loader.cpp", validation_execution_threads=1, + complete_tensor_validation=True, owner_poll_between_tensors=True) + + +def validate_build_provenance(value, require): + # Build v3 adds an explicit, optional generation extension to the same ABI. + # Historical patched build v2 retains its exact absent-capability shape. + overlay = value.get("source_overlay") if type(value) is dict else None + validate_constraints(value, 2, require, "NATIVE_BUILD_PATCH_BINDING") + require(type(value) is dict + and value.get("source_commit") == SOURCE and value.get("source_tree") == SOURCE_TREE + and type(overlay) is dict and overlay == loader_provenance() + and type(overlay["validation_execution_threads"]) is int + and overlay["complete_tensor_validation"] is True and overlay["owner_poll_between_tensors"] is True + and value.get("loader_compile_source_verified") is True + and value.get("upstream_original_unchanged") is True, "NATIVE_BUILD_PATCH_BINDING") + validate_cpu_provenance(value, require) + + +def cpu_provenance(): + return dict(kind="q1_avx2_aligned_load_v1", original_path=CPU_ORIGINAL_PATH, + original_sha256=CPU_ORIGINAL_SHA, effective_sha256=CPU_EFFECTIVE_SHA, + patch_path="native-cpu/q1-aligned-load.patch", patch_sha256=CPU_PATCH_SHA, + compiled_source="cpu-overlay/quants.c") + + +def validate_cpu_provenance(value, require): + # An additive source correction, not a new model/generation capability. + # Historical builds omit both fields and retain their unresolved Q1 finding. + present = {key for key in ("cpu_source_overlay", "cpu_compile_source_verified") if key in value} + if not present: + return + require(present == {"cpu_source_overlay", "cpu_compile_source_verified"} + and type(value.get("version")) is int and value["version"] == 4 + and type(value.get("cpu_source_overlay")) is dict + and value["cpu_source_overlay"] == cpu_provenance() + and value.get("cpu_compile_source_verified") is True, "NATIVE_CPU_PATCH_BINDING") + + +def validate_manifest(value, require): + constraints = validate_constraints(value, 1, require) + fields = {"version", "kind", "abi_version", "source_commit", + "model_profile", "source_weights_sha256", "source_weights_bytes", "library", "gguf", + "verification", "build_manifest_sha256"} + require(value.keys() == fields | ({"output_constraints"} if constraints else set()) + | ({"native_sessions"} if value["version"] == 3 else set()), "NATIVE_MANIFEST_FIELDS") + require(type(value["abi_version"]) is int and value["abi_version"] == 1 + and value["kind"] == KIND and value["source_commit"] == SOURCE + and value["model_profile"] == PROFILE and value["source_weights_sha256"] == WEIGHTS_SHA + and type(value["source_weights_bytes"]) is int and value["source_weights_bytes"] == WEIGHTS_BYTES, + "NATIVE_MANIFEST_BINDING") + for field, name, maximum in (("library", LIBRARY, 128 * 1024 * 1024), + ("gguf", "model.gguf", 9 * 1024 ** 3)): + item = value[field] + require(type(item) is dict and item.keys() == {"path", "sha256", "bytes"} + and item["path"] == name and type(item["sha256"]) is str and HEX.fullmatch(item["sha256"]) + and type(item["bytes"]) is int and 0 < item["bytes"] <= maximum, + "NATIVE_ARTIFACT_BINDING") + checked = value["verification"] + require(type(checked) is dict and checked.keys() == {"tensor_count", "tensor_values_equal", "tokenizer_equal", + "chat_template_sha256"} + and type(checked["tensor_count"]) is int and checked["tensor_count"] == 398 + and checked["tensor_values_equal"] is True and checked["tokenizer_equal"] is True + and type(checked["chat_template_sha256"]) is str and HEX.fullmatch(checked["chat_template_sha256"]) + and type(value["build_manifest_sha256"]) is str and HEX.fullmatch(value["build_manifest_sha256"]), + "NATIVE_CONVERSION_BINDING") + return value + + +def verified_bundle(request, worker, tokenizer, session): + require = worker.require + require(request.get("inference_backend") == KIND and request.get("model_profile") == PROFILE + and request["mode"] == "private_conversation", "NATIVE_BACKEND_SCOPE") + root, meta = worker.plain_path(request["native_backend_root"], True) + require(not stat.S_IMODE(meta.st_mode) & 0o022, "NATIVE_BACKEND_WRITABLE") + expected = request["native_backend_sha256"] + require(type(expected) is str and HEX.fullmatch(expected), "NATIVE_MANIFEST_DIGEST") + raw = worker.read_bounded(root / "backend.json", 16384) + require(hashlib.sha256(raw).hexdigest() == expected, "NATIVE_MANIFEST_DIGEST") + value = validate_manifest(worker.parse_json(raw), require) + for field in ("library", "gguf"): + item = value[field] + require(worker.file_hash(root / item["path"], expected_size=item["bytes"], session=session)["sha256"] == item["sha256"], + "NATIVE_ARTIFACT_DIGEST") + build = worker.read_bounded(root / "build.json", 65536) + require(hashlib.sha256(build).hexdigest() == value["build_manifest_sha256"], "NATIVE_BUILD_DIGEST") + provenance = worker.parse_json(build) + validate_build_provenance(provenance, require) + require(provenance["version"] == value["version"] + 1 + and provenance.get("output_constraints") == value.get("output_constraints") + and provenance.get("native_sessions") == value.get("native_sessions"), + "NATIVE_OUTPUT_CONSTRAINT_BINDING") + if "native_session" in request: + require(value.get("native_sessions") == [NATIVE_SESSION], "NATIVE_SESSION_UNSUPPORTED") + if "output_constraint" in request: + require(request["output_constraint"] == OUTPUT_CONSTRAINT + and value.get("output_constraints") == [OUTPUT_CONSTRAINT], + "NATIVE_OUTPUT_CONSTRAINT_UNSUPPORTED") + require(type(provenance) is dict + and provenance.get("kind") == KIND and provenance.get("source_commit") == SOURCE + and provenance.get("abi_version") == 1 and provenance.get("cpu") == "avx2_fma_f16c" + and provenance.get("provisionable") is True and provenance.get("sanitizers") is False + and provenance.get("quantization") is False and provenance.get("library") == value["library"], + "NATIVE_BUILD_BINDING") + require(type(tokenizer.chat_template) is str and hashlib.sha256(tokenizer.chat_template.encode()).hexdigest() + == value["verification"]["chat_template_sha256"], "NATIVE_TEMPLATE_BINDING") + identity = dict(kind=KIND, abi_version=1, source_commit=SOURCE, manifest_sha256=expected, + library_sha256=value["library"]["sha256"], gguf_sha256=value["gguf"]["sha256"], + gguf_bytes=value["gguf"]["bytes"], source_weights_sha256=WEIGHTS_SHA, + verification_scope=verification_scope()) + return root, value, identity + + +class OwnerProbe: + """No exception/ACK crosses C. Pause waits for the current bounded decode. + + Frames retain their exact bytes/order until the native operation has joined. + Then Session.check processes them and acknowledges stopped execution normally. + Cancellation and deadline can abort native work; that handle is never resumed. + """ + def __init__(self, session, worker): + self.session, self.worker = session, worker + self.pending = [] + self.error = None + self.lock = threading.Lock() + self.expected = session.sequence + self.callback = ABORT(self.poll) + + def poll(self, _opaque=None): + with self.lock: + if self.error is not None: + return 1 + try: + self.session.budget() + frames = self.session.frames + while frames is not None: + raw = frames.control(0) + if raw is None: + break + value = self.worker.parse_json(raw) + self.worker.require(type(value) is dict and value.keys() == {"version", "id", "sequence", "action"} + and type(value["version"]) is int and value["version"] == 1 + and value["id"] == self.session.request["id"] + and type(value["sequence"]) is int and value["sequence"] == self.expected + 1 + and value["sequence"] <= self.worker.MAX_CONTROLS + and type(value["action"]) is str + and value["action"] in ("pause", "resume", "cancel"), "INVALID_NATIVE_CONTROL") + self.expected = value["sequence"] + self.worker.require(value["action"] != "cancel", "JOB_CANCELLED") + self.pending.append(raw) + self.session.budget() + except BaseException as error: + # BaseException too: ctypes must never swallow a Python exception + # and accidentally return an unspecified value to native code. + self.error = (error if isinstance(error, self.worker.JobError) + else self.worker.JobError("NATIVE_OWNER_CONTROL_FAILED")) + return 1 + return 0 + + def joined(self): + if self.error is not None: + raise self.error + if self.pending: + self.session.frames.pending[:0] = b"".join(self.pending) + self.pending.clear() + self.session.check() + self.expected = self.session.sequence + + +class NativeModel: + def __init__(self, root, capacity, threads, probe, require, *, output_constraints=None, + output_constraint=None, output_constraint_tools=None, native_session=False, + context_capacity=False): + self.handle = ctypes.c_void_p() + self.require, self.probe = require, probe + self.capacity, self.context_capacity = capacity, context_capacity + require(type(context_capacity) is bool and (not context_capacity or native_session), + "NATIVE_SESSION_ABI_MISMATCH") + # The caller checked owner-authorized digest before this executable load. + self.lib = ctypes.CDLL(str(root / LIBRARY), mode=0) + signatures = { + "vp_llama_abi_v1": (ctypes.c_uint32, []), + "vp_llama_source_v1": (ctypes.c_char_p, []), + "vp_llama_open_stage_v1": (ctypes.c_uint32, []), + "vp_llama_open_v1": (ctypes.c_int32, [ctypes.c_char_p, ctypes.c_uint32, ctypes.c_uint32, + ABORT, ctypes.c_void_p, ctypes.POINTER(ctypes.c_void_p)]), + "vp_llama_decode_v1": (ctypes.c_int32, [ctypes.c_void_p, ctypes.POINTER(ctypes.c_int32), ctypes.c_uint32]), + "vp_llama_sample_v1": (ctypes.c_int32, [ctypes.c_void_p, ctypes.POINTER(ctypes.c_int32)]), + "vp_llama_close_v1": (None, [ctypes.c_void_p]), + } + for name, (result, arguments) in signatures.items(): + try: + function = getattr(self.lib, name) + except AttributeError as error: + require(False, "NATIVE_ABI_MISMATCH") + raise error # require always raises; no incomplete ABI can proceed. + function.restype, function.argtypes = result, arguments + require(self.lib.vp_llama_abi_v1() == 1 and self.lib.vp_llama_source_v1() == SOURCE.encode(), + "NATIVE_ABI_MISMATCH") + if output_constraints is not None: + require(output_constraints == [OUTPUT_CONSTRAINT], "NATIVE_OUTPUT_CONSTRAINT_BINDING") + for name, result, arguments in ( + ("vp_llama_output_constraint_abi_v1", ctypes.c_uint32, []), + ("vp_llama_set_output_constraint_v1", ctypes.c_int32, + [ctypes.c_void_p, ctypes.c_uint32, ctypes.c_uint32])): + require(hasattr(self.lib, name), "NATIVE_OUTPUT_CONSTRAINT_ABI_MISMATCH") + function = getattr(self.lib, name) + function.restype, function.argtypes = result, arguments + extension = self.lib.vp_llama_output_constraint_abi_v1() + require(type(extension) is int and extension == 1, + "NATIVE_OUTPUT_CONSTRAINT_ABI_MISMATCH") + if output_constraint is not None or output_constraint_tools is not None: + require(output_constraints == [OUTPUT_CONSTRAINT] and output_constraint == OUTPUT_CONSTRAINT + and type(output_constraint_tools) is list and 1 <= len(output_constraint_tools) <= 32 + and output_constraint_tools == [f"vp_{index}" for index in range(len(output_constraint_tools))], + "NATIVE_OUTPUT_CONSTRAINT_BINDING") + if native_session: + for name, result, arguments in ( + ("vp_llama_native_session_abi_v1", ctypes.c_uint32, []), + ("vp_llama_begin_turn_v1", ctypes.c_int32, + [ctypes.c_void_p, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_uint32, ABORT, ctypes.c_void_p])): + require(hasattr(self.lib, name), "NATIVE_SESSION_ABI_MISMATCH") + function = getattr(self.lib, name) + function.restype, function.argtypes = result, arguments + extension = self.lib.vp_llama_native_session_abi_v1() + require(type(extension) is int and extension == 1, "NATIVE_SESSION_ABI_MISMATCH") + if context_capacity: + for name, result, arguments in ( + ("vp_llama_context_capacity_abi_v1", ctypes.c_uint32, []), + ("vp_llama_reserve_context_v1", ctypes.c_int32, + [ctypes.c_void_p, ctypes.c_uint32, ABORT, ctypes.c_void_p])): + require(hasattr(self.lib, name), "NATIVE_SESSION_ABI_MISMATCH") + function = getattr(self.lib, name) + function.restype, function.argtypes = result, arguments + extension = self.lib.vp_llama_context_capacity_abi_v1() + require(type(extension) is int and extension == 1, "NATIVE_SESSION_ABI_MISMATCH") + try: + status = self.lib.vp_llama_open_v1(str(root / "model.gguf").encode(), capacity, threads, + probe.callback, None, ctypes.byref(self.handle)) + probe.joined() + stage = self.lib.vp_llama_open_stage_v1() + codes = {1: "NATIVE_OPEN_INVALID_ARGUMENT", 2: "NATIVE_CPU_UNSUPPORTED", + 3: "NATIVE_BACKEND_INITIALIZATION_FAILED", 4: "NATIVE_MODEL_INITIALIZATION_FAILED", + 5: "NATIVE_VOCABULARY_MISMATCH", 6: "NATIVE_CONTEXT_INITIALIZATION_FAILED", + 7: "NATIVE_SAMPLER_INITIALIZATION_FAILED", 8: "NATIVE_MODEL_LOAD_FAILED"} + require(type(stage) is int and stage in codes, "NATIVE_OPEN_DIAGNOSTIC_INVALID") + require(status == 0 and self.handle.value is not None, codes[stage]) + require(stage == 8, "NATIVE_OPEN_DIAGNOSTIC_INVALID") + if output_constraint is not None: + status = self.lib.vp_llama_set_output_constraint_v1(self.handle, 1, len(output_constraint_tools)) + probe.joined() + require(status == 0, "NATIVE_OUTPUT_CONSTRAINT_CONFIGURATION_FAILED") + except BaseException: + self.close() + raise + + def decode(self, tokens): + self.require(type(tokens) is list and 1 <= len(tokens) <= 128 + and all(type(token) is int and 0 <= token < 151936 for token in tokens), "NATIVE_TOKEN_BOUND") + values = (ctypes.c_int32 * len(tokens))(*tokens) + status = self.lib.vp_llama_decode_v1(self.handle, values, len(tokens)) + self.probe.joined() + self.require(status == 0, "NATIVE_DECODE_FAILED") + + def sample(self): + token = ctypes.c_int32(-1) + status = self.lib.vp_llama_sample_v1(self.handle, ctypes.byref(token)) + self.probe.joined() + self.require(status == 0 and 0 <= token.value < 151936, "NATIVE_SAMPLE_FAILED") + return token.value + + def begin_turn(self, retained_tokens, probe, constraint, aliases): + self.require(type(retained_tokens) is int and 0 <= retained_tokens < 12288, + "NATIVE_SESSION_PREFIX_BOUND") + self.require((constraint is None and aliases is None) or + (constraint == OUTPUT_CONSTRAINT and type(aliases) is list + and 1 <= len(aliases) <= 32 + and aliases == [f"vp_{index}" for index in range(len(aliases))]), + "NATIVE_OUTPUT_CONSTRAINT_BINDING") + # The old callback remains alive until the synchronous ABI handover has + # returned. No native work or callback may outlive this one execution thread. + previous = self.probe + status = self.lib.vp_llama_begin_turn_v1(self.handle, retained_tokens, + 0 if constraint is None else 1, 0 if aliases is None else len(aliases), probe.callback, None) + self.probe = probe + probe.joined() + self.require(status == 0, "NATIVE_SESSION_PREFIX_RESET_FAILED") + del previous + + def reserve_context(self, capacity, probe): + self.require(self.context_capacity and type(capacity) is int + and self.capacity < capacity <= 12288 + 1024, "NATIVE_SESSION_PREFIX_BOUND") + # Keep the old callback alive across the synchronous handover, exactly + # as begin_turn does. The ABI installs this turn's owner/deadline first. + previous = self.probe + status = self.lib.vp_llama_reserve_context_v1(self.handle, capacity, probe.callback, None) + self.probe = probe + probe.joined() + self.require(status == 0, "NATIVE_CONTEXT_INITIALIZATION_FAILED") + self.capacity = capacity + del previous + + def close(self): + if self.handle.value is not None: + self.lib.vp_llama_close_v1(self.handle) + self.handle = ctypes.c_void_p() + + +def generate(model, prompt, tokenizer, profile, session, worker, observation=None, *, retained_tokens=0, + decoded_tokens=None): + worker.require(type(prompt) is list and 1 <= len(prompt) <= profile["prompt_tokens"] + and all(type(token) is int and 0 <= token < 151936 for token in prompt), "NATIVE_PROMPT_BOUND") + if observation is not None: + observation.forward_begin(None, None) + # This is the complete original HF-rendered prompt in bounded prefill batches, + # not truncation, sliding context, a second prompt or a hidden warmup. + worker.require(type(retained_tokens) is int and 0 <= retained_tokens < len(prompt), "NATIVE_SESSION_PREFIX_BOUND") + for start in range(retained_tokens, len(prompt), 128): + session.check() + model.decode(prompt[start:start + 128]) + if observation is not None: + observation.forward_complete(None, None, None) + generated = [] + while len(generated) < profile["new_tokens"]: + session.check() + token = model.sample() + generated.append(token) + if observation is not None: + observation.tokens(len(generated)) + if token == tokenizer.eos_token_id: + break + if len(generated) < profile["new_tokens"]: + model.decode([token]) + metadata = worker.generation_metadata(generated, tokenizer.eos_token_id, PROFILE) + visible = generated[:-1] if metadata["stop_reason"] == "eos" else generated + raw = tokenizer.decode(visible, skip_special_tokens=False) + text = raw[:profile["wire_bytes"]] + while len(json.dumps(text, ensure_ascii=True).encode("ascii")) > profile["wire_bytes"]: + text = text[:-1] + if observation is not None: + observation.complete() + if decoded_tokens is not None: + # The final sampled token was not decoded (EOS or generation bound). + # Prefix reuse can only name positions actually present in this handle. + decoded_tokens[:] = prompt + generated[:-1] + return {"sample_index": 0, "text": text, "generated_tokens": len(generated), + "text_truncated": text != raw, "generation": metadata} + + +def execute(request, session, tokenizer, torch, versions, model_root, output_root, + dataset, data_identity, model_files, worker, *, initial_source_weights, retained=None): + worker.require(worker.conversation_module().generation_policy(dataset, PROFILE) == "greedy_v1", + "NATIVE_GREEDY_POLICY_REQUIRED") + constraint = worker.conversation_constraint_binding(request, dataset, PROFILE) + constraint_options = {} + if constraint is not None: + # The core supplies aliases, never arbitrary grammar. Validate against the + # actual ordered input, not a separate count controlled by a peer. + aliases = [f"vp_{index}" for index in range(len(dataset["tools"]))] + constraint_options.update(output_constraint=constraint, output_constraint_tools=aliases) + profile = worker.model_profile(PROFILE) + worker.require(type(initial_source_weights) is dict and initial_source_weights == profile["weights"] + and all(model_files.get(name) == {"bytes": profile["files"][name], + "sha256": profile["hashes"][name]} for name in initial_source_weights["files"]), + "NATIVE_SOURCE_PROVENANCE") + # Carry measured provenance, not a digest reconstructed from expected pins. + # Only the GGUF below is used by NativeModel. The original source files are + # not claimed to have been checked again at the end of this execution. + weight_identity = dict(initial_source_weights, files=list(initial_source_weights["files"])) + root, manifest, identity = verified_bundle(request, worker, tokenizer, session) + if "output_constraints" in manifest: + constraint_options["output_constraints"] = manifest["output_constraints"] + session.private_progress("prompt_encode", "begin") + prompt = worker.conversation_module().encode(tokenizer, dataset, profile) + session.private_progress("prompt_encode", "complete") + session.check() + probe = OwnerProbe(session, worker) + session.private_progress("model_load", "begin") + worker.require((retained is not None) == ("native_session" in request), "NATIVE_SESSION_BINDING") + prefix, decoded = 0, [] + if retained is None: + model = NativeModel(root, len(prompt) + profile["new_tokens"], request["threads"], probe, worker.require, + **constraint_options) + else: + model, prefix = retained.begin(root, manifest, identity, request, prompt, probe, worker) + try: + session.private_progress("model_load", "complete") + session.progress("baseline") + session.private_progress("generation", "begin") + observation = (worker.PrivateGenerationObservation(session, torch, len(prompt)) + if request.get("private_generation_diagnostics") is True else None) + output = generate(model, prompt, tokenizer, profile, session, worker, observation, + retained_tokens=prefix, decoded_tokens=decoded if retained is not None else None) + session.private_progress("generation", "complete") + finally: + if retained is None: + model.close() + session.private_progress("verify_after", "begin") + worker.require(worker.file_hash(root / "model.gguf", expected_size=manifest["gguf"]["bytes"], session=session)["sha256"] + == manifest["gguf"]["sha256"], "NATIVE_GGUF_CHANGED") + session.private_progress("verify_after", "complete") + result = {"version": 1, "id": request["id"], "kind": "result", "status": "ok", "mode": request["mode"], + "backend_versions": versions, "device": "cpu", "threads": request["threads"], + "model": {"id": profile["id"], "revision": profile["revision"], "files": model_files, + "weights": weight_identity}, "dataset": data_identity, "outputs": [output], + "updates_completed": 0, "artifacts": [], "model_weights_loaded": True, + "private_data_supported": True, "distributed_execution_claimed": False, + "private_training_claimed": False, "better_answers_claimed": False, "network_policy_changed": False, + "conversation": worker.conversation_module().decode(dataset, output, PROFILE, request["id"]), + "prompt_tokens": len(prompt), "conversation_limits": worker.conversation_module().capabilities(PROFILE, profile), + "generation_policy": "greedy_v1", "model_parameter_dtype": "bf16_with_exact_f32_norms", + "model_attention_backend": "llama_cpp_cpu", "inference_backend": identity} + if "output_constraint" in request: + result["output_constraint"] = request["output_constraint"] + session.private_progress("result", "begin") + result = worker.finish_result(result, output_root, session) + session.private_progress("result", "complete") + if retained is not None: + retained.tokens = decoded + return result + + +class RetainedModel: + """One interpreter/connection's private RAM; never global or serializable.""" + def __init__(self): + self.model, self.binding, self.tokens = None, None, [] + self.capacity, self.failed = 0, False + + def begin(self, root, manifest, identity, request, prompt, probe, worker): + worker.require(not self.failed, "NATIVE_SESSION_PREFIX_RESET_FAILED") + worker.require(manifest.get("native_sessions") == [NATIVE_SESSION], "NATIVE_SESSION_UNSUPPORTED") + binding = (str(root), json.dumps(identity, sort_keys=True), request["threads"]) + worker.require(self.binding is None or self.binding == binding, "NATIVE_SESSION_MODEL_CHANGED") + constraint, aliases = request.get("output_constraint"), request.get("output_constraint_tools") + profile = worker.model_profile(PROFILE) + worker.require(type(prompt) is list and 1 <= len(prompt) <= profile["prompt_tokens"] + and all(type(token) is int and 0 <= token < 151936 for token in prompt), "NATIVE_PROMPT_BOUND") + # Match the pinned native context's 256-token padding, not its maximum + # permitted context. The complete prompt and output allowance still fit. + capacity = ((len(prompt) + profile["new_tokens"] + 255) // 256) * 256 + worker.require(capacity <= 12288 + 1024, "NATIVE_PROMPT_BOUND") + prefix = 0 + previous = self.tokens + # A failed or growing turn cannot retain authorization to old KV. + self.tokens = [] + try: + if self.model is None: + self.model = NativeModel(root, capacity, request["threads"], probe, worker.require, + output_constraints=manifest["output_constraints"], native_session=True, context_capacity=True) + self.binding, self.capacity = binding, capacity + elif capacity > self.capacity: + # Growth keeps the one model but destroys the old context first. + # It costs prefix reuse for this turn, never any prompt tokens. + self.model.reserve_context(capacity, probe) + self.capacity = capacity + else: + for old, new in zip(previous, prompt[:-1]): + if old != new: + break + prefix += 1 + self.model.begin_turn(prefix, probe, constraint, aliases) + except BaseException: + self.failed = True + self.close() + raise + return self.model, prefix + + def close(self): + try: + if self.model is not None: + self.model.close() + finally: + self.model, self.binding, self.tokens = None, None, [] + self.capacity = 0 diff --git a/workers/volparossa-ml/native-converter-pins.json b/workers/volparossa-ml/native-converter-pins.json new file mode 100644 index 000000000..9b7824409 --- /dev/null +++ b/workers/volparossa-ml/native-converter-pins.json @@ -0,0 +1,61 @@ +{ + "format_version": 1, + "platform": "cpython-3.13-linux-x86_64", + "converter": { + "implementation": "llama_cpp_bf16_v1", + "dependencies": { + "sentencepiece": "0.2.1" + } + }, + "source_revision": "31646a467d2051eb904e0b45de3a73e91fe1c1e3", + "wheel_source_build_claimed": false, + "wheels": [ + { + "name": "sentencepiece", + "version": "0.2.1", + "path": "sentencepiece-0.2.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", + "bytes": 1387882, + "sha256": "c7f0fd2f2693309e6628aeeb2e2faf6edd221134dfccac3308ca0de01f8dab47", + "url": "https://files.pythonhosted.org/packages/96/df/0cfe748ace5485be740fed9476dee7877f109da32ed0d280312c94ec259f/sentencepiece-0.2.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", + "license": "Apache-2.0", + "metadata_sha256": "ce0a2e9c599cd060a7bc3dffb18be5cfc3d5f293533e3ee4e870eaef0a5500fd" + } + ], + "notices": [ + { + "path": "native-cpu/sentencepiece-LICENSE", + "source": "LICENSE", + "bytes": 11358, + "sha256": "cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30", + "url": "https://raw.githubusercontent.com/google/sentencepiece/31646a467d2051eb904e0b45de3a73e91fe1c1e3/LICENSE" + }, + { + "source": "third_party/absl/LICENSE", + "bytes": 11365, + "sha256": "d3e2f59e1d71176dfdb555ece6a41f7a5aa0f52ff21211010ace314f57695f6b", + "url": "https://raw.githubusercontent.com/google/sentencepiece/31646a467d2051eb904e0b45de3a73e91fe1c1e3/third_party/absl/LICENSE", + "path": "native-cpu/sentencepiece-absl-LICENSE" + }, + { + "source": "third_party/darts_clone/LICENSE", + "bytes": 1481, + "sha256": "155f59997298ee336602c49f9c1110f268ac394ca2197eb02647a3555935ad52", + "url": "https://raw.githubusercontent.com/google/sentencepiece/31646a467d2051eb904e0b45de3a73e91fe1c1e3/third_party/darts_clone/LICENSE", + "path": "native-cpu/sentencepiece-darts_clone-LICENSE" + }, + { + "source": "third_party/esaxx/LICENSE", + "bytes": 1112, + "sha256": "7c28553d1d3312d65fe309f76a22ebaf33a3d76c8c1e3b98a88ee4654ecb53db", + "url": "https://raw.githubusercontent.com/google/sentencepiece/31646a467d2051eb904e0b45de3a73e91fe1c1e3/third_party/esaxx/LICENSE", + "path": "native-cpu/sentencepiece-esaxx-LICENSE" + }, + { + "source": "third_party/protobuf-lite/LICENSE", + "bytes": 1732, + "sha256": "6e5e117324afd944dcf67f36cf329843bc1a92229a8cd9bb573d7a83130fea7d", + "url": "https://raw.githubusercontent.com/google/sentencepiece/31646a467d2051eb904e0b45de3a73e91fe1c1e3/third_party/protobuf-lite/LICENSE", + "path": "native-cpu/sentencepiece-protobuf-lite-LICENSE" + } + ] +} diff --git a/workers/volparossa-ml/native-converter-requirements.lock b/workers/volparossa-ml/native-converter-requirements.lock new file mode 100644 index 000000000..5b7e1eac5 --- /dev/null +++ b/workers/volparossa-ml/native-converter-requirements.lock @@ -0,0 +1,2 @@ +# Explicit BF16 converter only; baseline requirements.lock is unchanged. +sentencepiece==0.2.1 --hash=sha256:c7f0fd2f2693309e6628aeeb2e2faf6edd221134dfccac3308ca0de01f8dab47 diff --git a/workers/volparossa-ml/native-cpu/CMakeLists.txt b/workers/volparossa-ml/native-cpu/CMakeLists.txt new file mode 100644 index 000000000..4b2eb2fa9 --- /dev/null +++ b/workers/volparossa-ml/native-cpu/CMakeLists.txt @@ -0,0 +1,125 @@ +# SPDX-License-Identifier: GPL-3.0-only +cmake_minimum_required(VERSION 3.25) +project(volparossa_llama_cpu LANGUAGES C CXX) +if(NOT CMAKE_SYSTEM_NAME STREQUAL "Linux" OR NOT CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|amd64|AMD64)$") + message(FATAL_ERROR "This explicit backend is Linux amd64 only") +endif() +if(NOT DEFINED VP_LLAMA_SOURCE) + message(FATAL_ERROR "A verified local source checkout is required") +endif() +set(CMAKE_POSITION_INDEPENDENT_CODE ON) +set(CMAKE_CXX_STANDARD 17) +option(VP_NATIVE_TESTS "Build bounded inert native ABI/upstream tensor checks" OFF) +option(VP_NATIVE_SANITIZE "Instrument source-only check artifacts, never provision them" OFF) +if(VP_NATIVE_SANITIZE) + add_compile_options(-fsanitize=address,undefined -fno-omit-frame-pointer) + add_link_options(-fsanitize=address,undefined) +endif() +set(CMAKE_BUILD_TYPE Release CACHE STRING "" FORCE) +foreach(flag BUILD_SHARED_LIBS LLAMA_BUILD_COMMON LLAMA_BUILD_TESTS LLAMA_BUILD_TOOLS + LLAMA_BUILD_EXAMPLES LLAMA_BUILD_SERVER LLAMA_BUILD_APP LLAMA_BUILD_UI + LLAMA_OPENSSL LLAMA_SUBPROCESS LLAMA_LLGUIDANCE LLAMA_USE_SYSTEM_GGML + GGML_NATIVE GGML_BACKEND_DL GGML_CPU_ALL_VARIANTS GGML_OPENMP GGML_OPENMP_FETCH + GGML_BLAS GGML_CUDA GGML_HIP GGML_METAL GGML_VULKAN GGML_SYCL GGML_RPC + GGML_CPU_KLEIDIAI GGML_CPU_HBM GGML_CPU_REPACK GGML_CCACHE + GGML_SSE42 GGML_BMI2 GGML_AVX_VNNI GGML_AVX512 + GGML_AVX512_VBMI GGML_AVX512_VNNI GGML_AVX512_BF16 GGML_AMX_TILE GGML_AMX_INT8 GGML_AMX_BF16) + set(${flag} OFF CACHE BOOL "Explicit fixed CPU build" FORCE) +endforeach() +set(GGML_AVX ON CACHE BOOL "" FORCE) +set(GGML_AVX2 ON CACHE BOOL "" FORCE) +set(GGML_FMA ON CACHE BOOL "" FORCE) +set(GGML_F16C ON CACHE BOOL "" FORCE) +set(GGML_LLAMAFILE ON CACHE BOOL "" FORCE) +set(GGML_CPU ON CACHE BOOL "" FORCE) +add_subdirectory("${VP_LLAMA_SOURCE}" llama EXCLUDE_FROM_ALL) +# Keep the pinned checkout pristine. Only this exact source is replaced, and +# build_llama_cpu.py verifies its digest and actual compilation-database entry. +if(NOT IS_ABSOLUTE "${VP_LLAMA_MODEL_LOADER}" OR NOT EXISTS "${VP_LLAMA_MODEL_LOADER}") + message(FATAL_ERROR "The verified staged model loader is required") +endif() +get_target_property(llama_source_dir llama SOURCE_DIR) +get_target_property(llama_sources llama SOURCES) +set(effective_sources) +set(loader_replacements 0) +foreach(source IN LISTS llama_sources) + if(NOT IS_ABSOLUTE "${source}") + get_filename_component(source "${source}" ABSOLUTE BASE_DIR "${llama_source_dir}") + endif() + if(source STREQUAL "${VP_LLAMA_SOURCE}/src/llama-model-loader.cpp") + list(APPEND effective_sources "${VP_LLAMA_MODEL_LOADER}") + math(EXPR loader_replacements "${loader_replacements} + 1") + else() + list(APPEND effective_sources "${source}") + endif() +endforeach() +if(NOT loader_replacements EQUAL 1) + message(FATAL_ERROR "Expected exactly one original model loader") +endif() +set_property(TARGET llama PROPERTY SOURCES "${effective_sources}") +set_source_files_properties("${VP_LLAMA_MODEL_LOADER}" TARGET_DIRECTORY llama + PROPERTIES SKIP_UNITY_BUILD_INCLUSION ON) +# The only CPU-source change is the reviewed unaligned Q1 scalar load. Keep +# generic quants.c and every other translation unit at their original source. +if(NOT IS_ABSOLUTE "${VP_LLAMA_CPU_QUANTS}" OR NOT EXISTS "${VP_LLAMA_CPU_QUANTS}") + message(FATAL_ERROR "The verified staged x86 CPU source is required") +endif() +get_target_property(cpu_source_dir ggml-cpu SOURCE_DIR) +get_target_property(cpu_sources ggml-cpu SOURCES) +set(effective_cpu_sources) +set(cpu_replacements 0) +foreach(source IN LISTS cpu_sources) + if(NOT IS_ABSOLUTE "${source}") + get_filename_component(source "${source}" ABSOLUTE BASE_DIR "${cpu_source_dir}") + endif() + if(source STREQUAL "${VP_LLAMA_SOURCE}/ggml/src/ggml-cpu/arch/x86/quants.c") + list(APPEND effective_cpu_sources "${VP_LLAMA_CPU_QUANTS}") + math(EXPR cpu_replacements "${cpu_replacements} + 1") + else() + list(APPEND effective_cpu_sources "${source}") + endif() +endforeach() +if(NOT cpu_replacements EQUAL 1) + message(FATAL_ERROR "Expected exactly one original x86 CPU quants source") +endif() +set_property(TARGET ggml-cpu PROPERTY SOURCES "${effective_cpu_sources}") +set_source_files_properties("${VP_LLAMA_CPU_QUANTS}" TARGET_DIRECTORY ggml-cpu + PROPERTIES SKIP_UNITY_BUILD_INCLUSION ON) +# Resolve the original quoted simd-mappings.h and ../../ headers without +# copying, patching or guessing any upstream header bytes. +target_include_directories(ggml-cpu PRIVATE "${VP_LLAMA_SOURCE}/ggml/src/ggml-cpu/arch/x86") +add_library(volparossa_llama_cpu SHARED adapter.cpp output-constraint.cpp) +target_include_directories(volparossa_llama_cpu PRIVATE "${VP_LLAMA_SOURCE}/src") +target_link_libraries(volparossa_llama_cpu PRIVATE llama) +target_compile_options(volparossa_llama_cpu PRIVATE -Wall -Wextra -Werror) +target_link_options(volparossa_llama_cpu PRIVATE -static-libstdc++ -static-libgcc + "-Wl,--exclude-libs,ALL" "-Wl,-z,defs" "-Wl,-z,relro,-z,now") +set_target_properties(volparossa_llama_cpu PROPERTIES BUILD_RPATH "" INSTALL_RPATH "") +if(VP_NATIVE_TESTS) + add_executable(vp-native-abi-smoke abi-smoke.cpp) + target_compile_options(vp-native-abi-smoke PRIVATE -UNDEBUG) + target_link_libraries(vp-native-abi-smoke PRIVATE volparossa_llama_cpu) + # Real turn-state transitions with backend callbacks, no model or KV claims. + add_executable(vp-native-session session-tests.cpp) + target_compile_options(vp-native-session PRIVATE -UNDEBUG -Wall -Wextra -Werror) + # The exact pinned grammar engine with synthetic token pieces; no model or inference. + add_executable(vp-native-output-constraint output-constraint-tests.cpp output-constraint.cpp) + target_include_directories(vp-native-output-constraint PRIVATE "${VP_LLAMA_SOURCE}/src") + target_compile_options(vp-native-output-constraint PRIVATE -UNDEBUG -Wall -Wextra -Werror) + target_link_libraries(vp-native-output-constraint PRIVATE llama) + # Launch/join statements are extracted from the exact staged loader. This + # exercises real ggml validation on tiny F32 arrays, never loads a model. + add_executable(vp-native-loader-validation loader-validation.cpp) + get_filename_component(loader_overlay_dir "${VP_LLAMA_MODEL_LOADER}" DIRECTORY) + target_include_directories(vp-native-loader-validation PRIVATE "${loader_overlay_dir}") + target_compile_options(vp-native-loader-validation PRIVATE -UNDEBUG) + target_link_libraries(vp-native-loader-validation PRIVATE ggml) + # Unchanged upstream test, generated toy tensors only. No model files or CLI. + add_executable(vp-upstream-tensor-tests "${VP_LLAMA_SOURCE}/tests/test-quantize-fns.cpp") + target_link_libraries(vp-upstream-tensor-tests PRIVATE ggml ggml-cpu) + # Separate BF16/F32-only proof; never replaces the all-type target above. + add_executable(vp-native-bf16-tensors bf16-tensors.cpp) + target_compile_definitions(vp-native-bf16-tensors PRIVATE + VP_UPSTREAM_TEST_FILE="${VP_LLAMA_SOURCE}/tests/test-quantize-fns.cpp") + target_link_libraries(vp-native-bf16-tensors PRIVATE ggml ggml-cpu) +endif() diff --git a/workers/volparossa-ml/native-cpu/README.md b/workers/volparossa-ml/native-cpu/README.md new file mode 100644 index 000000000..4eb4a4678 --- /dev/null +++ b/workers/volparossa-ml/native-cpu/README.md @@ -0,0 +1,195 @@ +# Explicit native CPU inference candidate + +This is an **opt-in experimental backend**, not a working-model or coding-quality +claim. The legacy PyTorch backend remains unchanged. Only owner-authorized local +private conversations using `qwen3-4b-instruct-2507-v1` and explicit `greedy_v1` +are eligible. This does not enable private execution on arbitrary peers. + +## What is fixed + +- llama.cpp source: `7fe450e19305b828c199d602c23a8337aaa1f03b`, the resolved + `v0.5.0` tag, from `https://github.com/ggml-org/llama.cpp.git` (MIT). The tag is + unsigned; its name is not executable authorization. +- Qwen source: `Qwen/Qwen3-4B-Instruct-2507` at + `cdbee75f17c01a7cc42f958dc650907174af0554` (Apache-2.0). The original three + safetensors shards and their concatenated raw-byte digest remain the model + identity. A GGUF has its **own** measured size and SHA-256. +- CPU only, AVX2 + FMA + F16C explicitly required and checked before backend + initialization. No assumption that AVX2 alone proves the other instructions. + Original upstream fallback builds without FMA/F16C did not compile; no + permissive compiler flags or undocumented source patches were substituted. +- No HTTP server, network downloads, tool execution, GPU offload, quantization, + context shifting, hidden warmup, smaller-model substitution or extra time. + +`adapter.h` is VOLPAROSSA ABI v1. Python passes bounded scalar arrays and opaque +handles, never guessed upstream struct layouts. llama/ggml and the C++ runtime +are statically linked into the owned library; only the checked platform C/math +loader libraries remain dynamic. Arbitrary dynamic search paths are rejected. + +## Optional tool-call output constraint + +The new `qwen_tagged_tool_json_v1` profile is explicit and additive. Backend +manifest v2 and build manifest v3 bind the implemented capability; historical +backend v1/build v2 remain valid without it. Conversation IPC negotiates version +1 separately, and the core derives `vp_0` through `vp_N-1` from the actual offered +tools. Callers cannot supply grammar or executable code. Requests without tools +omit the selector, and requests without the selector retain the old greedy path. + +For selected Qwen4B/greedy requests, ordinary answers remain possible. If the +model begins a tagged tool call, native candidate filtering limits it to one +offered alias and an object-valued JSON argument. Container depth is at most 16, +counting the arguments object as depth 1, not the fixed call envelope. Token +lookahead checks split markers, their trailing bytes and UTF-8 before accepting +a candidate. No generated response is repaired or replaced after generation. +The existing strict final parser, tool authorization, prompt/output limits, +deadlines and cleanup still apply. This is not arbitrary JSON-schema validation +or proof that a proposed tool action is useful or authorized. + +The source-only hosted workflow builds normal and ASan/UBSan variants from the +exact upstream pin, without models. New checks run before the unchanged all-type +tensor check so its known sanitizer failure cannot hide their result. That +failure is neither suppressed nor converted into a passing full-suite result. +Native compilation, real-model behavior and a successful OpenCode task remain +separate acceptance requirements for the new candidate. + +## Source build and conversion + +`../build_llama_cpu.py` consumes a clean local checkout of the exact commit. It +does not acquire source, install dependencies or load a model. Preview is the +default; `--execute` builds at low priority with at most two compiler jobs into +a new output directory. `build.json` retains the source/tree, wrapper hashes, +compiler versions, actual library hash, CPU requirements and dependency list. +Original upstream notices are copied unchanged. +Build manifest v2 also binds the explicitly recorded loader patch, original +and effective loader digests, and actual staged-source compilation. The checkout +stays pristine; old v1 build manifests cannot masquerade as the patched backend. +See [third-party provenance](../../../THIRD_PARTY_LICENSES.md#explicit-llamacpp-cpu-inference-candidate). +The actual static GCC runtime archives are hashed; their original Debian GCC +copyright/runtime-exception notice is retained alongside upstream notices. + +The optional `--checks --sanitize` build runs an owned inert ABI smoke, a +separately scoped BF16/F32 wrapper, and the unchanged upstream all-type synthetic +tensor test under ASan/UBSan. Such artifacts are marked **not provisionable**. +These are not model or performance tests; the wrapper does not replace or +change the all-type test or its tolerances. + +Both loader paths previously launched a separate asynchronous validation task per +tensor, independently of the inference thread setting. The local patch defers +those checks to one execution thread and polls the existing owner callback between +tensors. Every check still runs before successful loading; cancellation rejects +the load and frees its state. A single tensor check is not internally interruptible, +so this is not a guarantee of maximum cancellation latency. Pause acknowledgment +still waits for native execution to stop. + +A new source-derived harness executes both patched launch paths and the actual +validation-result loop on 398 tiny F32 arrays. It verifies complete checking even +when the first tensor is invalid, cancellation before selected checks and a fresh +complete retry. The normal source build passes that harness, the ABI smoke, +BF16/F32 scope and original all-type test. A separate ASan/UBSan execution covers +the new harness/extracted statements only, not the already built ggml archive. +It does not supersede the historical full-suite sanitizer failure below. + +The additive ABI-v1 last-open-stage accessor reports only fixed phase numbers +for model, vocabulary, context and sampler initialization. The worker and core +reject unknown stages and retain only allowlisted diagnostics, never exception +text, model paths, tensor names or user content. Missing accessors are refused. + +The original all-type sanitizer check **failed** on a misaligned `uint32_t` +load in upstream `ggml/src/ggml-cpu/arch/x86/quants.c:590`, +`ggml_vec_dot_q1_0_q8_0`. Its retained 412-byte log has SHA-256 +`37e07701e4f9a7fadbee0876f77d7eb034192e951a4cd2da572606255bf84f08`. +Q1_0/Q8_0 model tensors are not admitted by this BF16/F32 conversion contract, +and the historical failure remains in build provenance. + +The separate alignment candidate stages a second exact source patch: one AVX2 +pointer-cast load becomes a 16-byte `memcpy` into an aligned `uint32_t[4]`. +It changes no arithmetic or accepted model format. CMake substitutes only the +x86 CPU translation unit; the build and converter verify its digest, selected +compile-database entry, compiler input and `ggml-cpu` object target. The upstream +checkout, headers and all-type test remain unchanged. + +Build v4 adds the atomic `cpu_source_overlay` / `cpu_compile_source_verified` +pair for this correction. Historical manifests omitting both fields retain their +old meaning; they are not evidence that the alignment issue is corrected. +The new wrapper inventory has eight files, including the patch, so downstream +Code provenance must be explicitly joined before another paired model trial. +Seven inert alignment/provenance cases and the existing 91 Python cases pass; +the new normal and complete ASan/UBSan source builds remain required. The full +sanitizer suite must not yet be described as passing. + +A separate owned wrapper reused the original upstream BF16 conversion, +reference and dot-product functions and tolerances, plus its F32 vector cases. +That narrower ASan/UBSan check passed with no model loaded; its 1,776-byte log +has SHA-256 `6fac1d1773ae750583092ed00014e40983d319811a67afea385cc014fe514e46`. +The original all-type failure and source archives were verified unchanged. + +`../convert_llama_cpu.py` requires the existing explicit disposable guest/CI +guard, original pinned runtime/model files, local pinned source and a matching +normal source build. It uses the upstream offline BF16 converter and then +independently checks all 398 tensor names, shapes and values against the +original shards. Norm/one-dimensional tensors may be promoted from BF16 to +F32 **exactly**, including signed-zero bits; arbitrary F32 matrices, F16, +quantized tensors, changed values or converter fallback are rejected. The +reported precision is `bf16_with_exact_f32_norms`, not a claim that every +stored tensor is BF16. + +The converter also checks model parameters, vocabulary IDs, merges, EOS and the +original chat template. Runtime prompt encoding and output decoding still use +the pinned Hugging Face tokenizer, not an alternate llama.cpp chat template. + +`../provision.py --model-profile qwen3-4b-instruct-2507-v1` accepts the optional +pair `--native-cpu-source ABS --native-cpu-build ABS`. Conversion shares the +**original** provisioning disk budget and deadline; insufficient room or time +is a refusal, not permission to raise limits. The new `native-backend/` artifact +has owner-private directories (`0700`) and read-only files (`0400`). Existing +provisioning without these options retains its original behavior/report shape. + +Only this explicit conversion adds the official CPython 3.13/Linux x86-64 +`sentencepiece==0.2.1` wheel, making 39 wheels instead of the unchanged baseline +38. `native-converter-pins.json` and `native-converter-requirements.lock` bind +its exact URL, 1,387,882-byte size and SHA-256; provisioning retains the combined +pins/lock and includes the extra bytes in the same budget. This dependency is +required even for Qwen's non-SentencePiece vocabulary: the pinned upstream +converter imports SentencePiece before checking for the absent `tokenizer.model` +and taking its normal vocabulary fallback. No upstream patch is substituted. +The wheel's metadata was inspected without installing/importing it on the host; +its version/import check runs only in the existing disposable provision runtime. +The published wheel is not claimed to be a reproducible local source build. +Original release and bundled dependency licenses are retained unchanged and +copied into converted artifacts; the wheel itself contains no license files. + +The converter child remains in the existing owned provisioning process group. +On a local timeout or TERM/INT/HUP, its wrapper joins that exact child before +returning, without signalling the shared parent group. If an outer timeout +SIGKILLs the wrapper, its handler cannot run: the child still belongs to the +original group, which the fixture must terminate and join. Harmless real-process +tests cover both cases, including a SIGTERM-ignoring child and an unrelated +surviving sibling. This does not replace the fixture's final group-cleanup check. + +## Execution and trust + +The owner explicitly supplies `--native-backend-root` and +`--native-backend-sha256`, the hash of the actual `backend.json`. Self-declared +peer metadata cannot authorize executable loading. The core maps the selected +directory read-only; the isolated worker verifies the manifest and complete +library/GGUF/build hashes **before** loading the fixed library. + +All original prompt tokens are supplied in batches of at most 128 tokens; +batching does not truncate or restart the context. The same KV state is retained +across pause/resume. Only actual generated EOS ends a successful generation; +raw model text still passes through the existing strict native tool parser and +owner approvals. A proposal is not permission to edit or run commands. + +Native callbacks buffer bounded original control frames without acknowledging +pause while computation is active. Cancellation/deadline becomes a fixed abort +status; exceptions are propagated only after native execution has joined. Pause +and resume are acknowledged at stopped decode boundaries by the existing owner +protocol. All handles are closed deterministically, and the supervisor retains +its independent hard-budget and process-cleanup authority. + +Actual guest conversion has passed in the preserved trials, including complete +tensor-value verification. The latest unpatched-loader trial stopped during model +loading; neither its unique failure cause nor successful native inference is +established. Patched loading, memory/performance feasibility, real EOS completion +and the unchanged OpenCode read/edit/test scenario still require their own guest +evidence. A source build, ABI smoke or synthetic tensor test is not that evidence. diff --git a/workers/volparossa-ml/native-cpu/abi-smoke.cpp b/workers/volparossa-ml/native-cpu/abi-smoke.cpp new file mode 100644 index 000000000..f78981688 --- /dev/null +++ b/workers/volparossa-ml/native-cpu/abi-smoke.cpp @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: GPL-3.0-only +// No model is loaded. Invalid calls and abort-before-file-open exercise our real +// compiled ABI; upstream tensor tests are a separate target, not a model proof. +#include "adapter.h" +#include +#include + +static int32_t abort_immediately(void * opaque) { + ++*static_cast(opaque); + return 1; +} + +int main() { + assert(vp_llama_abi_v1() == 1); + assert(vp_llama_output_constraint_abi_v1() == 1); + assert(vp_llama_native_session_abi_v1() == 1); + assert(vp_llama_context_capacity_abi_v1() == 1); + assert(vp_llama_reserve_context_v1(nullptr, 2048, nullptr, nullptr) == VP_INVALID); + assert(vp_llama_begin_turn_v1(nullptr, 0, 0, 0, nullptr, nullptr) == VP_INVALID); + assert(vp_llama_set_output_constraint_v1(nullptr, 1, 1) == VP_INVALID); + assert(vp_llama_open_stage_v1() == VP_OPEN_NONE); + assert(std::strcmp(vp_llama_source_v1(), "7fe450e19305b828c199d602c23a8337aaa1f03b") == 0); + void * handle = nullptr; + int callbacks = 0; + assert(vp_llama_open_v1(nullptr, 16, 2, abort_immediately, &callbacks, &handle) == VP_INVALID); + assert(vp_llama_open_stage_v1() == VP_OPEN_ARGUMENT); + assert(vp_llama_open_v1("relative", 16, 2, abort_immediately, &callbacks, &handle) == VP_INVALID); + assert(vp_llama_open_v1("/never-read-model", 13313, 2, abort_immediately, &callbacks, &handle) == VP_INVALID); + assert(vp_llama_open_v1("/never-read-model", 16, 3, abort_immediately, &callbacks, &handle) == VP_INVALID); + assert(vp_llama_open_v1("/never-read-model", 16, 2, nullptr, nullptr, &handle) == VP_INVALID); + assert(vp_llama_decode_v1(nullptr, nullptr, 0) == VP_INVALID); + assert(vp_llama_sample_v1(nullptr, nullptr) == VP_INVALID); + assert(handle == nullptr && callbacks == 0); + const auto status = vp_llama_open_v1("/never-read-model", 16, 2, abort_immediately, &callbacks, &handle); + // Unsupported instruction set is a legitimate explicit refusal, not a pass + // of model loading. On this capable build host, the callback aborts first. + assert((status == VP_ABORTED && callbacks == 1) || (status == VP_INVALID && callbacks == 0)); + assert(vp_llama_open_stage_v1() == (status == VP_ABORTED ? VP_OPEN_BACKEND : VP_OPEN_CPU)); + assert(handle == nullptr); + vp_llama_close_v1(nullptr); +} diff --git a/workers/volparossa-ml/native-cpu/adapter.cpp b/workers/volparossa-ml/native-cpu/adapter.cpp new file mode 100644 index 000000000..e89f4efbe --- /dev/null +++ b/workers/volparossa-ml/native-cpu/adapter.cpp @@ -0,0 +1,307 @@ +// SPDX-License-Identifier: GPL-3.0-only +#include "adapter.h" +#include "llama.h" +#include "output-constraint.h" +#include "session-state.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { +constexpr uint32_t max_context = 12288 + 1024; +constexpr uint32_t max_batch = 128; +constexpr int32_t vocabulary = 151936; +constexpr const char * source = "7fe450e19305b828c199d602c23a8337aaa1f03b"; +struct state : vp::session_state { + llama_model * model = nullptr; + llama_context * context = nullptr; + llama_sampler * sampler = nullptr; + vp_abort_v1 abort = nullptr; + void * opaque = nullptr; + uint32_t capacity = 0; + uint32_t threads = 0; + std::unique_ptr constraint; + ~state() { + if (context) { llama_synchronize(context); } + constraint.reset(); + if (sampler) { llama_sampler_free(sampler); } + if (context) { llama_free(context); } + if (model) { llama_model_free(model); } + } +}; +bool aborted(void * pointer) { + auto * value = static_cast(pointer); + return value->abort(value->opaque) != 0; +} +bool load_progress(float, void * pointer) { return !aborted(pointer); } +llama_context * create_context(state * value, uint32_t capacity) { + auto params = llama_context_default_params(); + params.n_ctx = capacity; + params.n_batch = max_batch; + params.n_ubatch = max_batch; + params.n_seq_max = 1; + params.n_threads = static_cast(value->threads); + params.n_threads_batch = static_cast(value->threads); + params.type_k = GGML_TYPE_BF16; + params.type_v = GGML_TYPE_BF16; + params.flash_attn_type = LLAMA_FLASH_ATTN_TYPE_DISABLED; + params.offload_kqv = false; + params.op_offload = false; + params.abort_callback = aborted; + params.abort_callback_data = value; + return llama_init_from_model(value->model, params); +} +bool token_piece(const llama_vocab * vocab, llama_token token, std::string & piece) { + std::array bytes{}; + const auto size = llama_token_to_piece(vocab, token, bytes.data(), bytes.size(), 0, true); + if (size < 1 || size > static_cast(bytes.size())) { return false; } + piece.assign(bytes.data(), static_cast(size)); + return true; +} + +int32_t constrained_sample(state * value, int32_t * output) { + if (value->generated >= 1024 || (!value->generated && value->used > 12288)) { + value->failed = true; return VP_INVALID; + } + const auto * logits = llama_get_logits_ith(value->context, -1); + if (!logits) { value->failed = true; return VP_BACKEND; } + const auto * vocab = llama_model_get_vocab(value->model); + std::vector candidates; + candidates.reserve(vocabulary); + for (int32_t id = 0; id < vocabulary; ++id) { + if ((id % 4096) == 0 && aborted(value)) { value->failed = true; return VP_ABORTED; } + if (std::isfinite(logits[id]) && (id == 151645 || !llama_vocab_is_control(vocab, id))) { + candidates.push_back({id, logits[id], 0.0f}); + } + } + if (candidates.empty()) { value->failed = true; return VP_BACKEND; } + llama_token_data_array array{candidates.data(), candidates.size(), -1, false}; + value->constraint->filter(array); + // Linear heap construction, then highest finite permitted logit. Ascending + // token ID breaks ties exactly as the unchanged upstream greedy sampler. + std::string piece; + llama_token selected = -1; + const auto result = vp::select_candidate(candidates, [&](llama_token id) { + piece.clear(); + const bool eos = id == 151645; + return (eos || token_piece(vocab, id, piece)) && value->constraint->permits(piece, eos); + }, [&] { return aborted(value); }, selected); + if (result == vp::selection::chosen && !aborted(value)) { + value->constraint->accept(piece, selected == 151645); + *output = selected; + ++value->generated; + value->ready = false; + return VP_OK; + } + value->failed = true; + return result == vp::selection::cancelled || aborted(value) ? VP_ABORTED : VP_BACKEND; +} +void discard_log(ggml_log_level, const char *, void *) {} +std::once_flag initialized; +thread_local uint32_t open_stage = VP_OPEN_NONE; +} + +extern "C" uint32_t vp_llama_abi_v1(void) { return 1; } +extern "C" const char * vp_llama_source_v1(void) { return source; } +extern "C" uint32_t vp_llama_open_stage_v1(void) { return open_stage; } +extern "C" uint32_t vp_llama_output_constraint_abi_v1(void) { return 1; } +extern "C" uint32_t vp_llama_native_session_abi_v1(void) { return 1; } +extern "C" uint32_t vp_llama_context_capacity_abi_v1(void) { return 1; } + +extern "C" int32_t vp_llama_reserve_context_v1(void * handle, uint32_t capacity, + vp_abort_v1 abort, void * opaque) { + auto * value = static_cast(handle); + if (!value) { return VP_INVALID; } + if (!abort || !value->context || !value->model || !value->sampler) { + value->ready = false; + value->failed = true; + return VP_INVALID; + } + // Replace the expired previous turn's callback before any owner check. + value->abort = abort; + value->opaque = opaque; + return vp::grow_session_context(*value, value->capacity, capacity, + [&] { return aborted(value); }, + [&] { llama_synchronize(value->context); }, + [&] { + value->constraint.reset(); + llama_sampler_reset(value->sampler); + llama_free(value->context); + value->context = nullptr; + }, + [&](uint32_t requested) { + value->context = create_context(value, requested); + return value->context != nullptr; + }); +} + +extern "C" int32_t vp_llama_begin_turn_v1(void * handle, uint32_t retained, + uint32_t mode, uint32_t count, + vp_abort_v1 abort, void * opaque) { + auto * value = static_cast(handle); + if (!value) { return VP_INVALID; } + if (!abort || value->failed || !((mode == 0 && count == 0) || + (mode == 1 && count >= 1 && count <= 32))) { + value->ready = false; + value->failed = true; + return VP_INVALID; + } + // The context's original callback still points to this handle; only the + // handle-local owner callback changes, before any new backend operation. + value->abort = abort; + value->opaque = opaque; + return vp::begin_session_turn(*value, retained, value->capacity, + [&] { return aborted(value); }, + [&] { llama_synchronize(value->context); }, + [&](uint32_t prefix) { + const auto memory = llama_get_memory(value->context); + return memory && llama_memory_seq_rm(memory, 0, static_cast(prefix), -1); + }, + [&] { + std::unique_ptr next; + if (mode == 1) { + const auto * vocab = llama_model_get_vocab(value->model); + std::string open, close; + if (llama_vocab_is_control(vocab, 151657) || llama_vocab_is_control(vocab, 151658) || + !token_piece(vocab, 151657, open) || open != "" || + !token_piece(vocab, 151658, close) || close != "") { + throw std::invalid_argument("constraint vocabulary"); + } + next = std::make_unique(vocab, count); + } + llama_sampler_reset(value->sampler); + value->constraint = std::move(next); + }); +} + +extern "C" int32_t vp_llama_set_output_constraint_v1(void * handle, uint32_t mode, uint32_t count) { + auto * value = static_cast(handle); + if (value && value->session_active) { return vp::invalid_session(value); } + if (!value || value->failed || value->used || value->constraint || mode != 1 || count < 1 || count > 32) { + return VP_INVALID; + } + try { + if (aborted(value)) { value->failed = true; return VP_ABORTED; } + const auto * vocab = llama_model_get_vocab(value->model); + std::string open, close; + if (llama_vocab_is_control(vocab, 151657) || llama_vocab_is_control(vocab, 151658) || + !token_piece(vocab, 151657, open) || open != "" || + !token_piece(vocab, 151658, close) || close != "") { return VP_INVALID; } + value->constraint = std::make_unique(vocab, count); + if (aborted(value)) { value->failed = true; return VP_ABORTED; } + return VP_OK; + } catch (...) { value->failed = true; return VP_BACKEND; } +} + +extern "C" int32_t vp_llama_open_v1(const char * path, uint32_t capacity, + uint32_t threads, vp_abort_v1 abort, + void * opaque, void ** handle) { + open_stage = VP_OPEN_ARGUMENT; + if (!handle) { return VP_INVALID; } + *handle = nullptr; + if (!path || path[0] != '/' || std::strlen(path) > 4096 || !abort || + capacity < 2 || capacity > max_context || threads < 1 || threads > 2) { + return VP_INVALID; + } + open_stage = VP_OPEN_CPU; +#if defined(__x86_64__) && defined(__GNUC__) + if (!__builtin_cpu_supports("avx2") || !__builtin_cpu_supports("fma") || + !__builtin_cpu_supports("f16c")) { return VP_INVALID; } +#else + return VP_INVALID; +#endif + try { + open_stage = VP_OPEN_BACKEND; + std::call_once(initialized, [] { + // No private text, paths, tensor names or backend errors on stdout/stderr. + llama_log_set(discard_log, nullptr); + ggml_log_set(discard_log, nullptr); + llama_backend_init(); + }); + auto value = std::make_unique(); + value->abort = abort; + value->opaque = opaque; + value->capacity = capacity; + value->threads = threads; + if (aborted(value.get())) { return VP_ABORTED; } + auto model_params = llama_model_default_params(); + model_params.n_gpu_layers = 0; + model_params.use_extra_bufts = false; + model_params.check_tensors = true; + model_params.progress_callback = load_progress; + model_params.progress_callback_user_data = value.get(); + open_stage = VP_OPEN_MODEL; + value->model = llama_model_load_from_file(path, model_params); + if (aborted(value.get())) { return VP_ABORTED; } + if (!value->model) { return VP_BACKEND; } + open_stage = VP_OPEN_VOCABULARY; + const auto * vocab = llama_model_get_vocab(value->model); + if (llama_vocab_n_tokens(vocab) != vocabulary || llama_vocab_eos(vocab) != 151645) { + return VP_INVALID; + } + open_stage = VP_OPEN_CONTEXT; + value->context = create_context(value.get(), capacity); + if (aborted(value.get())) { return VP_ABORTED; } + if (!value->context) { return VP_BACKEND; } + open_stage = VP_OPEN_SAMPLER; + value->sampler = llama_sampler_init_greedy(); + if (!value->sampler) { return VP_BACKEND; } + *handle = value.release(); + open_stage = VP_OPEN_READY; + return VP_OK; + } catch (...) { return VP_BACKEND; } +} + +extern "C" int32_t vp_llama_decode_v1(void * handle, const int32_t * tokens, uint32_t count) { + auto * value = static_cast(handle); + if (!value || !tokens || value->failed || !count || count > max_batch || + value->used > value->capacity || count > value->capacity - value->used) { + return vp::invalid_session(value); + } + if (value->session_active && value->generated == 0 && value->used + count > 12288) { + return vp::invalid_session(value); + } + for (uint32_t i = 0; i < count; ++i) { + if (tokens[i] < 0 || tokens[i] >= vocabulary) { return vp::invalid_session(value); } + } + try { + value->ready = false; + if (aborted(value)) { value->failed = true; return VP_ABORTED; } + // Upstream's synchronous decode never borrows this array after return. + auto batch = llama_batch_get_one(const_cast(tokens), static_cast(count)); + const auto result = llama_decode(value->context, batch); + llama_synchronize(value->context); + if (aborted(value)) { value->failed = true; return VP_ABORTED; } + if (result != 0) { value->failed = true; return VP_BACKEND; } + value->used += count; + value->ready = true; + return VP_OK; + } catch (...) { value->failed = true; return VP_BACKEND; } +} + +extern "C" int32_t vp_llama_sample_v1(void * handle, int32_t * token) { + auto * value = static_cast(handle); + if (!value || !token || value->failed || !value->ready) { return vp::invalid_session(value); } + try { + if (aborted(value)) { value->failed = true; return VP_ABORTED; } + if (value->session_active && !vp::session_sample_allowed(*value)) { + value->failed = true; return VP_INVALID; + } + if (value->constraint) { return constrained_sample(value, token); } + const auto generated = llama_sampler_sample(value->sampler, value->context, -1); + if (generated < 0 || generated >= vocabulary) { value->failed = true; return VP_BACKEND; } + *token = generated; + if (value->session_active) { ++value->generated; } + value->ready = false; + return VP_OK; + } catch (...) { value->failed = true; return VP_BACKEND; } +} + +extern "C" void vp_llama_close_v1(void * handle) { delete static_cast(handle); } diff --git a/workers/volparossa-ml/native-cpu/adapter.h b/workers/volparossa-ml/native-cpu/adapter.h new file mode 100644 index 000000000..899a645d8 --- /dev/null +++ b/workers/volparossa-ml/native-cpu/adapter.h @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: GPL-3.0-only +#pragma once +#include + +#ifdef __cplusplus +extern "C" { +#endif + +// Our versioned ABI, not a copied upstream struct layout. A handle is owned by +// one Python execution thread. Callback exceptions must never cross this ABI. +typedef int32_t (*vp_abort_v1)(void *); +enum vp_status_v1 { VP_OK = 0, VP_INVALID = 1, VP_ABORTED = 2, VP_BACKEND = 3 }; +// Closed last-open phase on the calling execution thread. No exception text or +// paths cross the ABI. Existing v1 entry points/status values remain unchanged. +enum vp_open_stage_v1 { + VP_OPEN_NONE = 0, VP_OPEN_ARGUMENT = 1, VP_OPEN_CPU = 2, + VP_OPEN_BACKEND = 3, VP_OPEN_MODEL = 4, VP_OPEN_VOCABULARY = 5, + VP_OPEN_CONTEXT = 6, VP_OPEN_SAMPLER = 7, VP_OPEN_READY = 8 +}; +uint32_t vp_llama_abi_v1(void); +const char * vp_llama_source_v1(void); +uint32_t vp_llama_open_stage_v1(void); +// Optional, additive ABI. Mode 1 is qwen_tagged_tool_json_v1, with aliases +// vp_0..vp_(tool_count-1), count 1..32 and JSON nesting <=16 (arguments object +// is depth 1; fixed envelope excluded). Configure once before any decode. +// Absence preserves the original greedy_v1 path. +uint32_t vp_llama_output_constraint_abi_v1(void); +int32_t vp_llama_set_output_constraint_v1(void * handle, uint32_t mode, uint32_t tool_count); +// Optional same-owner, same-thread in-memory session. No state crosses handles +// or owners. The caller retains only the exact token LCP of already decoded +// tokens, at most new_prompt_length-1, and must decode a nonempty suffix before +// sampling. Mode/count is either 0/0 (greedy) or 1/1..32 (fresh fixed grammar). +// A failed begin/decode/sample requires close, never a cold fallback. The caller +// keeps each callback/opaque alive until replacement or close has returned. +uint32_t vp_llama_native_session_abi_v1(void); +int32_t vp_llama_begin_turn_v1(void * handle, uint32_t retained_tokens, + uint32_t constraint_mode, uint32_t tool_count, + vp_abort_v1 abort, void * opaque); +// Optional context-capacity extension for an already active session. Grow only, +// at most 13312 tokens, under the new turn's callback. Synchronize and destroy +// the old context before allocating its replacement around the SAME model. +// Success discards all previous KV/logits; begin_turn_v1(..., 0, ...) and the +// entire new prompt must follow. Any failure poisons the handle until close. +// Callers lacking this extension keep their original fixed-capacity behavior. +uint32_t vp_llama_context_capacity_abi_v1(void); +int32_t vp_llama_reserve_context_v1(void * handle, uint32_t context_tokens, + vp_abort_v1 abort, void * opaque); +int32_t vp_llama_open_v1(const char * model_path, uint32_t context_tokens, + uint32_t threads, vp_abort_v1 abort, void * opaque, + void ** handle); +int32_t vp_llama_decode_v1(void * handle, const int32_t * tokens, uint32_t count); +int32_t vp_llama_sample_v1(void * handle, int32_t * token); +void vp_llama_close_v1(void * handle); + +#ifdef __cplusplus +} +#endif diff --git a/workers/volparossa-ml/native-cpu/bf16-tensors.cpp b/workers/volparossa-ml/native-cpu/bf16-tensors.cpp new file mode 100644 index 000000000..679e9bcb5 --- /dev/null +++ b/workers/volparossa-ml/native-cpu/bf16-tensors.cpp @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: GPL-3.0-only +// A deliberately scoped test driver, not a replacement for the unchanged +// all-type upstream test. Reuse its actual functions and tolerances verbatim. +// The original upstream main remains compiled under a different name. +#define main vp_upstream_all_types_main +#include VP_UPSTREAM_TEST_FILE +#undef main + +int main() { + ggml_cpu_init(); + int failed = test_vec_dot_f32(true); + const auto * traits = ggml_get_type_traits(GGML_TYPE_BF16); + const auto * cpu = ggml_get_type_traits_cpu(GGML_TYPE_BF16); + assert(traits->to_float && traits->from_float_ref && cpu->from_float && cpu->vec_dot); + constexpr size_t count = 32 * 128; + std::vector first(count); + std::vector second(count); + generate_data(0.0, count, first.data()); + generate_data(1.0, count, second.data()); + const float errors[] = { + total_quantization_error(traits, cpu, count, first.data()), + reference_quantization_error(traits, cpu, count, first.data()), + dot_product_error(cpu, GGML_TYPE_BF16, count, first.data(), second.data(), nullptr, nullptr, 1), + }; + const float limits[] = {MAX_QUANTIZATION_TOTAL_ERROR, MAX_QUANTIZATION_REFERENCE_ERROR, MAX_DOT_PRODUCT_ERROR}; + for (int index = 0; index < 3; ++index) { + const bool bad = !(errors[index] < limits[index]); + failed += bad; + printf("BF16 original upstream check %d: %s (error=%f limit=%f)\n", + index, RESULT_STR[bad], errors[index], limits[index]); + } + printf("Owned BF16/F32 scope: %d failures; no model; upstream all-type result is separate\n", failed); + return failed > 0; +} diff --git a/workers/volparossa-ml/native-cpu/bounded-tensor-validation.patch b/workers/volparossa-ml/native-cpu/bounded-tensor-validation.patch new file mode 100644 index 000000000..6632c4b50 --- /dev/null +++ b/workers/volparossa-ml/native-cpu/bounded-tensor-validation.patch @@ -0,0 +1,32 @@ +--- a/src/llama-model-loader.cpp ++++ b/src/llama-model-loader.cpp +@@ -1648,7 +1648,7 @@ + uint8_t * data = (uint8_t *) mapping->addr() + weight->offs; + + if (check_tensors) { +- validation_result.emplace_back(std::async(std::launch::async, [cur, data, n_size] { ++ validation_result.emplace_back(std::async(std::launch::deferred, [cur, data, n_size] { + return std::make_pair(cur, ggml_validate_row_data(cur->type, data, n_size)); + })); + } +@@ -1676,7 +1676,7 @@ + file->seek(weight->offs, SEEK_SET); + file->read_raw(cur->data, n_size); + if (check_tensors) { +- validation_result.emplace_back(std::async(std::launch::async, [cur, n_size] { ++ validation_result.emplace_back(std::async(std::launch::deferred, [cur, n_size] { + return std::make_pair(cur, ggml_validate_row_data(cur->type, cur->data, n_size)); + })); + } +@@ -1763,6 +1763,11 @@ + // check validation results + bool validation_failed = false; + for (auto & future : validation_result) { ++ // Complete validation stays on this execution thread; poll owner cancellation ++ // between tensors without exposing data or acknowledging pause while loading. ++ if (progress_callback && !progress_callback((float) size_done / size_data, progress_callback_user_data)) { ++ return false; ++ } + auto result = future.get(); + if (!result.second) { + LLAMA_LOG_ERROR("%s: tensor '%s' has invalid data\n", __func__, ggml_get_name(result.first)); diff --git a/workers/volparossa-ml/native-cpu/loader-validation.cpp b/workers/volparossa-ml/native-cpu/loader-validation.cpp new file mode 100644 index 000000000..ce2247efa --- /dev/null +++ b/workers/volparossa-ml/native-cpu/loader-validation.cpp @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: GPL-3.0-only +// The included launch/join fragments come from the hash-verified staged MIT +// loader. Only the surrounding toy tensors and observation harness are ours. +// This is not a full model load or inference/latency proof. +#include "ggml.h" +#include +#include +#include +#include +#include +#include +#include + +static int validations = 0; +static std::thread::id execution_thread; +static bool checked_validation(ggml_type type, const void * data, size_t size) { + assert(std::this_thread::get_id() == execution_thread); + ++validations; + return ggml_validate_row_data(type, data, size); +} + +using pending = std::vector>>; +// Instrument calls, not their result: real ggml checks still run completely. +#define ggml_validate_row_data checked_validation +static void mapped(pending & validation_result, ggml_tensor * cur, uint8_t * data, size_t n_size) { +#include "validation-mapped.inc" +} +static void host(pending & validation_result, ggml_tensor * cur, size_t n_size) { +#include "validation-host.inc" +} +#undef ggml_validate_row_data + +static int polls = 0; +static int cancel_on_poll = 0; +static bool progress(float, void *) { + assert(std::this_thread::get_id() == execution_thread); + return ++polls != cancel_on_poll; +} +static bool join(pending & validation_result) { + const size_t size_done = 1, size_data = 1; + auto progress_callback = progress; + void * progress_callback_user_data = nullptr; + // The production logger remains disabled; the toy contains no private data. +#define LLAMA_LOG_ERROR(...) ((void) 0) +#include "validation-join.inc" +#undef LLAMA_LOG_ERROR + return true; +} + +static void run(bool invalid, int cancellation) { + constexpr int count = 398; + validations = polls = 0; + cancel_on_poll = cancellation; + execution_thread = std::this_thread::get_id(); + float valid[2] = {1.0f, -1.0f}; + float corrupt[2] = {std::numeric_limits::quiet_NaN(), 0.0f}; + std::vector tensors(count); + pending validation_result; + for (int i = 0; i < count; ++i) { + auto * cur = &tensors[i]; + cur->type = GGML_TYPE_F32; + cur->data = invalid && i == 0 ? corrupt : valid; + if (i % 2 == 0) { + mapped(validation_result, cur, static_cast(cur->data), sizeof(valid)); + } else { + host(validation_result, cur, sizeof(valid)); + } + } + assert(validations == 0); + for (auto & future : validation_result) { + assert(future.wait_for(std::chrono::seconds(0)) == std::future_status::deferred); + } + bool complete = false, rejected = false; + try { complete = join(validation_result); } + catch (const std::runtime_error &) { rejected = true; } + if (cancellation != 0) { + assert(!complete && !rejected && validations == cancellation - 1 && polls == cancellation); + } else { + assert(complete == !invalid && rejected == invalid && validations == count && polls == count); + } +} + +int main() { + run(false, 0); + run(true, 0); // A bad first tensor must not skip the other validation results. + run(false, 1); + run(false, 3); + run(false, 398); + run(false, 0); // A fresh attempt after cancellation checks every tensor again. +} diff --git a/workers/volparossa-ml/native-cpu/output-constraint-tests.cpp b/workers/volparossa-ml/native-cpu/output-constraint-tests.cpp new file mode 100644 index 000000000..ce96a710c --- /dev/null +++ b/workers/volparossa-ml/native-cpu/output-constraint-tests.cpp @@ -0,0 +1,235 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Real pinned upstream GBNF engine, synthetic pieces/logits only. No model, +// native inference, tool execution or protected-network claim. +#include "output-constraint.h" +#include +#include +#include + +namespace { +const std::string call = "{\"name\":\"vp_0\",\"arguments\":{\"input\":\"ok\"}}"; + +void feed(vp::tool_constraint & value, const std::string & text) { + assert(value.permits(text, false)); + value.accept(text, false); +} + +void ordinary_and_optional() { + vp::tool_constraint value(nullptr, 1); + assert(!value.permits("", true)); + feed(value, "A normal answer, not a tool."); + assert(value.permits("", true)); + value.accept("", true); + assert(!value.permits("more", false)); + for (unsigned count : {0u, 33u}) { + bool rejected = false; + try { vp::tool_constraint invalid(nullptr, count); } + catch (const std::invalid_argument &) { rejected = true; } + assert(rejected); + } +} + +void atomic_split_and_tail() { + for (std::size_t split = 0; split <= call.size(); ++split) { + vp::tool_constraint value(nullptr, 1); + if (split) { feed(value, call.substr(0, split)); } + if (split < call.size()) { feed(value, call.substr(split)); } + assert(value.permits("", true)); + assert(!value.permits(call, false)); // No second call after the first. + assert(!value.permits(" trailing prose", false)); + } + vp::tool_constraint value(nullptr, 1); + feed(value, "I will inspect it.\n{bad}", false)); + assert(!value.permits("call>{\"name\":\"vp_1\"", false)); + feed(value, "call>"); + assert(!value.permits("", true)); + assert(!value.permits("{\"arguments\":{}}", false)); + feed(value, "{\"name\":\"vp_0\",\"arguments\":{}}"); + assert(!value.permits("", true)); + feed(value, ""); + assert(value.permits("", true)); + // Invalid lookahead did not mutate the actual chosen grammar state. + value.accept("", true); +} + +void preface_and_aliases() { + for (const auto & prefix : {"Same line ", "```\n", "~~~\n", "\n", "\n"}) { + vp::tool_constraint value(nullptr, 1); + assert(!value.permits(prefix + call, false)); + assert(!value.permits(std::string(prefix) + "{\"name\":\"vp_31\",\"arguments\":{}}"); + assert(value.permits("", true)); + for (const auto & name : {"vp_32", "other", "vp_00"}) { + vp::tool_constraint invalid(nullptr, 32); + assert(!invalid.permits(std::string("{\"name\":\"") + name + "\",", false)); + } + vp::tool_constraint partial(nullptr, 1); + feed(partial, ""; + for (const auto & text : {std::string("caf\xc3\xa9"), std::string("\xf0\x9f\xa6\x8a"), + std::string("\\\"\\\\\\n\\u00e9\\uD83E\\uDD8A")}) { + vp::tool_constraint value(nullptr, 1); + feed(value, prefix); + for (char byte : text) { feed(value, std::string(1, byte)); } + feed(value, suffix); + assert(value.permits("", true)); + } + for (const auto & text : {std::string("\xc0\x80"), std::string("\xed\xa0\x80"), + std::string("\xf4\x90\x80\x80"), std::string("\\x00"), std::string("\\uD800x"), + std::string("\0", 1)}) { + vp::tool_constraint value(nullptr, 1); + feed(value, prefix); + assert(!value.permits(text + suffix, false)); + } + vp::tool_constraint partial(nullptr, 1); + feed(partial, "plain \xc3"); + assert(!partial.permits("", true)); + assert(!partial.permits("bad", false)); + feed(partial, "\xa9"); + assert(partial.permits("", true)); + vp::tool_constraint wrong_position(nullptr, 1); + assert(!wrong_position.permits("\xc3", false)); +} + +void depth_and_wire_bounds() { + for (unsigned depth : {16u, 17u}) { + vp::tool_constraint value(nullptr, 1); + std::string body = "{}"; + for (unsigned level = 1; level < depth; ++level) { body = "{\"a\":" + body + "}"; } + const std::string text = "{\"name\":\"vp_0\",\"arguments\":" + body + "}"; + assert(value.permits(text, false) == (depth == 16)); + } + vp::tool_constraint exact(nullptr, 1); + feed(exact, std::string(4094, 'a')); + assert(exact.permits("", true)); + assert(!exact.permits("a", false)); + vp::tool_constraint escapes(nullptr, 1); + feed(escapes, std::string(2047, '\n')); + assert(!escapes.permits("\n", false)); + vp::tool_constraint long_piece(nullptr, 1); + assert(!long_piece.permits(std::string(4097, 'a'), false)); +} + +void json_values_and_incomplete_suffix() { + const std::string prefix = "{\"name\":\"vp_0\",\"arguments\":"; + const std::string suffix = "}"; + for (const auto & body : {"{}", "{\"v\":[null,true,false,-1,0,1.25e-3,{\"x\":[]}]}", + "{\"v\":\"a marker inside a JSON string\"}"}) { + vp::tool_constraint value(nullptr, 1); + feed(value, prefix + body + suffix); + assert(value.permits("", true)); + } + for (const auto & body : {"[]", "null", "{\"v\":01}", "{\"v\":1.}", "{\"v\":NaN}", + "{\"v\":+1}", "{\"v\":true,}", "{\"v\":[1,]}"}) { + vp::tool_constraint value(nullptr, 1); + assert(!value.permits(prefix + body + suffix, false)); + } + vp::tool_constraint partial(nullptr, 1); + feed(partial, prefix + "{}}x", false)); + feed(partial, "call>"); + assert(partial.permits("", true)); + assert(!partial.permits("\xc3", false)); // No incomplete UTF-8 after complete grammar. +} + +void finite_selection_and_cancel() { + vp::tool_constraint optional(nullptr, 1); + std::vector ordinary{{0, 3.0f, 0.0f}, {1, 2.0f, 0.0f}}; + llama_token ordinary_choice = -1; + assert(vp::select_candidate(ordinary, [&](llama_token id) { + return optional.permits(id == 0 ? "No tool is needed." : call, false); + }, [] { return false; }, ordinary_choice) == vp::selection::chosen); + assert(ordinary_choice == 0); // An offered tool never forces a tool call. + vp::tool_constraint value(nullptr, 1); + feed(value, " pieces{"call>{bad}", "call>{\"name\":\"vp_0\",\"arguments\":{}}"}; + const float inf = std::numeric_limits::infinity(); + const float nan = std::numeric_limits::quiet_NaN(); + std::vector candidates{{0, 100.0f, 0.0f}, {1, 1.0f, 0.0f}, + {2, inf, 0.0f}, {3, -inf, 0.0f}, {4, nan, 0.0f}}; + llama_token chosen = -1; + assert(vp::select_candidate(candidates, [&](llama_token id) { + assert(id == 0 || id == 1); + return value.permits(pieces[id], false); + }, [] { return false; }, chosen) == vp::selection::chosen); + assert(chosen == 1); + feed(value, pieces[chosen]); + assert(value.permits("", true)); + candidates = {{1, 2.0f, 0.0f}, {0, 2.0f, 0.0f}}; + assert(vp::select_candidate(candidates, [](llama_token) { return true; }, + [] { return false; }, chosen) == vp::selection::chosen); + assert(chosen == 0); // Same finite greedy tie order, not forced tool selection. + candidates = {{0, nan, 0.0f}, {1, inf, 0.0f}}; + assert(vp::select_candidate(candidates, [](llama_token) { assert(false); return true; }, + [] { return false; }, chosen) == vp::selection::empty); + assert(chosen == -1); + candidates = {{0, 1.0f, 0.0f}}; + assert(vp::select_candidate(candidates, [](llama_token) { return false; }, + [] { return false; }, chosen) == vp::selection::empty); + assert(chosen == -1); + candidates = {{0, 1.0f, 0.0f}}; + assert(vp::select_candidate(candidates, [](llama_token) { assert(false); return true; }, + [] { return true; }, chosen) == vp::selection::cancelled); + assert(chosen == -1); + candidates = {{0, 2.0f, 0.0f}, {1, 1.0f, 0.0f}}; + unsigned inspected = 0; + assert(vp::select_candidate(candidates, [&](llama_token) { ++inspected; return false; }, + [&] { return inspected != 0; }, chosen) == vp::selection::cancelled); + assert(inspected == 1 && chosen == -1); // Cancellation also during candidate rejection. +} + +void independent_turn_grammars() { + auto value = std::make_unique(nullptr, 1); + feed(*value, call); + value->accept("", true); + assert(!value->permits(call, false)); + // The native begin-turn path constructs a fresh grammar, not a clone of + // the previous tool call's ended state or its old offered alias set. + value = std::make_unique(nullptr, 2); + const std::string second = "{\"name\":\"vp_1\",\"arguments\":{}}"; + feed(*value, second); + value->accept("", true); + value = std::make_unique(nullptr, 1); + assert(!value->permits(second, false)); + feed(*value, "A fresh ordinary answer remains permitted."); + assert(value->permits("", true)); + value->accept("", true); + value = std::make_unique(nullptr, 1); + feed(*value, "(nullptr, 1); + feed(*value, call); // No unfinished marker from the old turn. + assert(value->permits("", true)); + value = std::make_unique(nullptr, 1); + feed(*value, "plain \xc3"); + assert(!value->permits("", true)); + value = std::make_unique(nullptr, 1); + feed(*value, call); // No incomplete UTF-8 state from the old turn. + assert(value->permits("", true)); +} +} + +int main() { + ordinary_and_optional(); + atomic_split_and_tail(); + preface_and_aliases(); + utf8_and_escapes(); + depth_and_wire_bounds(); + json_values_and_incomplete_suffix(); + finite_selection_and_cancel(); + independent_turn_grammars(); +} diff --git a/workers/volparossa-ml/native-cpu/output-constraint.cpp b/workers/volparossa-ml/native-cpu/output-constraint.cpp new file mode 100644 index 000000000..39c88b597 --- /dev/null +++ b/workers/volparossa-ml/native-cpu/output-constraint.cpp @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: GPL-3.0-only +#include "output-constraint.h" +#include "llama-grammar.h" +#include +#include +#include + +namespace vp { +namespace { +constexpr const char * opening = ""; +using grammar_ptr = std::unique_ptr; + +std::string grammar(unsigned count) { + if (count < 1 || count > 32) { throw std::invalid_argument("constraint count"); } + std::string result = R"gbnf(root ::= "" ws "{" ws "\"name\"" ws ":" ws tool-name ws "," ws "\"arguments\"" ws ":" ws obj1 ws "}" ws "" ws +ws ::= [ \t\n\r]* +string ::= "\"" ([^"\\\x00-\x1F] | "\\" (["\\/bfnrt] | "u" unicode))* "\"" +hex ::= [0-9a-fA-F] +unicode ::= [0-9a-cA-Ce-fE-F] hex hex hex | [dD] [0-7] hex hex | [dD] [89aAbB] hex hex "\\u" [dD] [c-fC-F] hex hex +number ::= "-"? ("0" | [1-9] [0-9]*) ("." [0-9]+)? ([eE] [+-]? [0-9]+)? +scalar ::= string | number | "true" | "false" | "null" +tool-name ::= )gbnf"; + for (unsigned index = 0; index < count; ++index) { + if (index) { result += " | "; } + result += "\"\\\"vp_" + std::to_string(index) + "\\\"\""; + } + result += '\n'; + for (unsigned depth = 1; depth <= tool_json_depth; ++depth) { + const auto suffix = std::to_string(depth); + const auto element = depth == tool_json_depth ? "scalar" : "value" + std::to_string(depth + 1); + result += "value" + suffix + " ::= scalar | obj" + suffix + " | arr" + suffix + "\n"; + result += "obj" + suffix + " ::= \"{\" ws (string ws \":\" ws " + element + + " (ws \",\" ws string ws \":\" ws " + element + ")*)? ws \"}\"\n"; + result += "arr" + suffix + " ::= \"[\" ws (" + element + " (ws \",\" ws " + + element + ")*)? ws \"]\"\n"; + } + return result; +} + +// Validate all bytes, including token-split UTF-8. The bound matches Python's +// ensure_ascii JSON string encoding, including its two surrounding quotes. +bool wire_prefix(const std::string & text, bool & complete) { + std::size_t cost = 2; + complete = true; + for (std::size_t index = 0; index < text.size();) { + const auto first = static_cast(text[index]); + if (!first) { return false; } + if (first < 0x80) { + cost += first == '"' || first == '\\' || first == '\b' || first == '\f' || + first == '\n' || first == '\r' || first == '\t' ? 2 : first < 0x20 || first == 0x7f ? 6 : 1; + ++index; + } else { + const unsigned length = first >= 0xc2 && first <= 0xdf ? 2 : + first >= 0xe0 && first <= 0xef ? 3 : first >= 0xf0 && first <= 0xf4 ? 4 : 0; + if (!length) { return false; } + cost += length == 4 ? 12 : 6; + for (unsigned offset = 1; offset < length; ++offset) { + if (index + offset >= text.size()) { complete = false; break; } + const auto next = static_cast(text[index + offset]); + if (next < 0x80 || next > 0xbf || (offset == 1 && + ((first == 0xe0 && next < 0xa0) || (first == 0xed && next > 0x9f) || + (first == 0xf0 && next < 0x90) || (first == 0xf4 && next > 0x8f)))) { return false; } + } + index += length; + } + if (cost > tool_json_wire_bytes) { return false; } + } + return true; +} + +bool preface(const std::string & text) { + if (text.find("(vocab, count)) {} +tool_constraint::~tool_constraint() = default; + +selection select_candidate(std::vector & candidates, + const std::function & permitted, + const std::function & cancelled, llama_token & selected) { + selected = -1; + if (cancelled()) { return selection::cancelled; } + candidates.erase(std::remove_if(candidates.begin(), candidates.end(), + [](const auto & candidate) { return !std::isfinite(candidate.logit); }), candidates.end()); + const auto lower = [](const auto & left, const auto & right) { + return left.logit < right.logit || (left.logit == right.logit && left.id > right.id); + }; + std::make_heap(candidates.begin(), candidates.end(), lower); + while (!candidates.empty()) { + if (cancelled()) { return selection::cancelled; } + const auto candidate = candidates.front(); + if (permitted(candidate.id)) { selected = candidate.id; return selection::chosen; } + std::pop_heap(candidates.begin(), candidates.end(), lower); + candidates.pop_back(); + } + return selection::empty; +} + +void tool_constraint::filter(llama_token_data_array & candidates) const { + if (value->active) { + if (!value->parser->vocab || !candidates.data || !candidates.size) { + throw std::invalid_argument("constraint filter"); + } + llama_grammar_apply_impl(*value->parser, &candidates); + } +} + +bool tool_constraint::permits(const std::string & piece, bool eos) const { + if (value->ended || (!eos && piece.empty()) || piece.size() > tool_json_wire_bytes || + value->text.size() + piece.size() > tool_json_wire_bytes) { return false; } + const auto next = value->text + piece; + bool complete = false; + if (!wire_prefix(next, complete)) { return false; } + for (const auto * forbidden : {""}) { + if (next.find(forbidden) != std::string::npos) { return false; } + } + if (eos) { + if (!piece.empty() || !complete) { return false; } + if (value->active) { return completed(*value->parser); } + return next.find_first_not_of(" \t\n\r\f\v") != std::string::npos && + next.find("active) { + try { return viable(*value->advance(piece)); } + catch (const std::runtime_error &) { return false; } + } + const auto start = next.find(opening); + if (start == std::string::npos) { + const auto partial = next.find("advance(next.substr(start))); } + catch (const std::runtime_error &) { return false; } +} + +void tool_constraint::accept(const std::string & piece, bool eos) { + if (!permits(piece, eos)) { throw std::invalid_argument("constraint candidate"); } + if (eos) { value->ended = true; return; } + const auto next = value->text + piece; + const auto start = next.find(opening); + if (value->active) { value->parser = value->advance(piece); } + else if (start != std::string::npos) { + value->parser = value->advance(next.substr(start)); + value->active = true; + } + value->text = next; +} +} diff --git a/workers/volparossa-ml/native-cpu/output-constraint.h b/workers/volparossa-ml/native-cpu/output-constraint.h new file mode 100644 index 000000000..b3d8d42a6 --- /dev/null +++ b/workers/volparossa-ml/native-cpu/output-constraint.h @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: GPL-3.0-only +#pragma once +#include "llama.h" +#include +#include +#include +#include + +namespace vp { +// Fixed, optional profile. No caller-provided grammar or schema is accepted. +constexpr unsigned tool_json_depth = 16; +constexpr unsigned tool_json_wire_bytes = 4096; +enum class selection { chosen, empty, cancelled }; +selection select_candidate(std::vector & candidates, + const std::function & permitted, + const std::function & cancelled, llama_token & selected); +class tool_constraint { +public: + tool_constraint(const llama_vocab * vocab, unsigned count); + ~tool_constraint(); + tool_constraint(const tool_constraint &) = delete; + tool_constraint & operator=(const tool_constraint &) = delete; + void filter(llama_token_data_array & candidates) const; + bool permits(const std::string & piece, bool eos) const; + void accept(const std::string & piece, bool eos); +private: + struct implementation; + std::unique_ptr value; +}; +} diff --git a/workers/volparossa-ml/native-cpu/q1-aligned-load.patch b/workers/volparossa-ml/native-cpu/q1-aligned-load.patch new file mode 100644 index 000000000..dad83555c --- /dev/null +++ b/workers/volparossa-ml/native-cpu/q1-aligned-load.patch @@ -0,0 +1,12 @@ +--- a/ggml/src/ggml-cpu/arch/x86/quants.c ++++ b/ggml/src/ggml-cpu/arch/x86/quants.c +@@ -580,7 +580,8 @@ + + for (int ib = 0; ib < nb; ++ib) { + const float d0 = GGML_CPU_FP16_TO_FP32(x[ib].d); +- const uint32_t * GGML_RESTRICT qs32 = (const uint32_t *) x[ib].qs; ++ uint32_t qs32[4]; ++ memcpy(qs32, x[ib].qs, sizeof(qs32)); + const block_q8_0 * GGML_RESTRICT y_ptr = &y[ib * 4]; + + __m256 acc_block; diff --git a/workers/volparossa-ml/native-cpu/sentencepiece-LICENSE b/workers/volparossa-ml/native-cpu/sentencepiece-LICENSE new file mode 100644 index 000000000..d64569567 --- /dev/null +++ b/workers/volparossa-ml/native-cpu/sentencepiece-LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/workers/volparossa-ml/native-cpu/sentencepiece-absl-LICENSE b/workers/volparossa-ml/native-cpu/sentencepiece-absl-LICENSE new file mode 100644 index 000000000..fef7d9678 --- /dev/null +++ b/workers/volparossa-ml/native-cpu/sentencepiece-absl-LICENSE @@ -0,0 +1,204 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + \ No newline at end of file diff --git a/workers/volparossa-ml/native-cpu/sentencepiece-darts_clone-LICENSE b/workers/volparossa-ml/native-cpu/sentencepiece-darts_clone-LICENSE new file mode 100644 index 000000000..28203b3b2 --- /dev/null +++ b/workers/volparossa-ml/native-cpu/sentencepiece-darts_clone-LICENSE @@ -0,0 +1,10 @@ +Copyright (c) 2008-2011, Susumu Yata +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +- Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. +- Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. +- Neither the name of the nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/workers/volparossa-ml/native-cpu/sentencepiece-esaxx-LICENSE b/workers/volparossa-ml/native-cpu/sentencepiece-esaxx-LICENSE new file mode 100644 index 000000000..07394df7c --- /dev/null +++ b/workers/volparossa-ml/native-cpu/sentencepiece-esaxx-LICENSE @@ -0,0 +1,24 @@ +This is the esaxx copyright. + +Copyright (c) 2010 Daisuke Okanohara All Rights Reserved. + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/workers/volparossa-ml/native-cpu/sentencepiece-protobuf-lite-LICENSE b/workers/volparossa-ml/native-cpu/sentencepiece-protobuf-lite-LICENSE new file mode 100644 index 000000000..19b305b00 --- /dev/null +++ b/workers/volparossa-ml/native-cpu/sentencepiece-protobuf-lite-LICENSE @@ -0,0 +1,32 @@ +Copyright 2008 Google Inc. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +Code generated by the Protocol Buffer compiler is owned by the owner +of the input file used when generating it. This code is not +standalone and requires a support library to be linked with it. This +support library is itself covered by the above license. diff --git a/workers/volparossa-ml/native-cpu/session-state.h b/workers/volparossa-ml/native-cpu/session-state.h new file mode 100644 index 000000000..52d81023f --- /dev/null +++ b/workers/volparossa-ml/native-cpu/session-state.h @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: GPL-3.0-only +#pragma once +#include "adapter.h" + +namespace vp { +// Process-local state of one handle, never a transferable cache or owner ID. +// Legacy calls leave session_active false and retain their existing behavior. +struct session_state { + uint32_t used = 0; + uint32_t generated = 0; + bool ready = false; + bool failed = false; + bool session_active = false; +}; + +inline int32_t invalid_session(session_state * value) { + if (value && value->session_active) { + value->ready = false; + value->failed = true; + } + return VP_INVALID; +} + +inline bool session_sample_allowed(const session_state & value) { + return value.generated < 1024 && (value.generated != 0 || value.used <= 12288); +} + +// The production callbacks operate on the same already-synchronized context. +// Tests can exercise every failure boundary without a model or synthetic KV +// contents being admitted as an inference result. Any partial reset poisons the +// handle: callers must close it, never silently clear/reload and continue. +template +int32_t begin_session_turn(session_state & value, uint32_t retained, uint32_t capacity, + Cancelled cancelled, Synchronize synchronize, Truncate truncate, Reset reset) { + value.ready = false; // Old logits can never be sampled after begin_turn. + if (value.failed || value.used > capacity || retained > value.used || + retained >= capacity || retained >= 12288) { + value.failed = true; + return VP_INVALID; + } + try { + if (cancelled()) { value.failed = true; return VP_ABORTED; } + synchronize(); + if (cancelled()) { value.failed = true; return VP_ABORTED; } + if (!truncate(retained)) { value.failed = true; return VP_BACKEND; } + if (cancelled()) { value.failed = true; return VP_ABORTED; } + reset(); + if (cancelled()) { value.failed = true; return VP_ABORTED; } + value.used = retained; + value.generated = 0; + value.session_active = true; + return VP_OK; + } catch (...) { + value.failed = true; + return VP_BACKEND; + } +} + +// The single model is outside this transition. Destroying the old context must +// complete before creating another; a failed or cancelled transition can only +// be closed. This is capacity growth, never a retry of an admitted generation. +template +int32_t grow_session_context(session_state & value, uint32_t & capacity, uint32_t requested, + Cancelled cancelled, Synchronize synchronize, Destroy destroy, Create create) { + value.ready = false; + if (value.failed || !value.session_active || value.used > capacity || + requested <= capacity || requested > 13312) { + value.failed = true; + return VP_INVALID; + } + try { + if (cancelled()) { value.failed = true; return VP_ABORTED; } + synchronize(); + if (cancelled()) { value.failed = true; return VP_ABORTED; } + destroy(); + if (cancelled()) { value.failed = true; return VP_ABORTED; } + if (!create(requested)) { value.failed = true; return VP_BACKEND; } + if (cancelled()) { value.failed = true; return VP_ABORTED; } + capacity = requested; + value.used = 0; + value.generated = 0; + return VP_OK; + } catch (...) { + value.failed = true; + return VP_BACKEND; + } +} +} diff --git a/workers/volparossa-ml/native-cpu/session-tests.cpp b/workers/volparossa-ml/native-cpu/session-tests.cpp new file mode 100644 index 000000000..4520bf999 --- /dev/null +++ b/workers/volparossa-ml/native-cpu/session-tests.cpp @@ -0,0 +1,248 @@ +// SPDX-License-Identifier: GPL-3.0-only +// Exercise the production turn-state transition with bounded callback doubles. +// No model, real KV cache, inference or cross-owner reuse is claimed. +#include "session-state.h" +#include +#include +#include + +namespace { +struct backend { + unsigned checks = 0, synchronized = 0, truncated = 0, resets = 0; + unsigned cancel_at = 99, throw_at = 99; + uint32_t observed_prefix = 99; + bool truncate_ok = true; + + int32_t begin(vp::session_state & value, uint32_t prefix, uint32_t capacity = 13312) { + return vp::begin_session_turn(value, prefix, capacity, + [&] { assert(!value.ready); return checks++ == cancel_at; }, + [&] { + ++synchronized; + if (throw_at == 0) { throw std::runtime_error("synthetic sync"); } + }, + [&](uint32_t retained) { + ++truncated; + observed_prefix = retained; + if (throw_at == 1) { throw std::runtime_error("synthetic truncate"); } + return truncate_ok; + }, + [&] { + ++resets; + if (throw_at == 2) { throw std::runtime_error("synthetic reset"); } + }); + } +}; + +vp::session_state previous() { + vp::session_state value; + value.used = 16; + value.generated = 4; + value.ready = true; + value.session_active = true; + return value; +} + +void fresh_and_retained_turns() { + vp::session_state first; + backend initial; + assert(initial.begin(first, 0) == VP_OK); + assert(first.session_active && !first.failed && !first.ready); + assert(first.used == 0 && first.generated == 0 && initial.observed_prefix == 0); + assert(initial.checks == 4 && initial.synchronized == 1 && initial.truncated == 1 && initial.resets == 1); + for (uint32_t prefix : {0u, 1u, 15u, 16u}) { + auto value = previous(); + backend next; + assert(next.begin(value, prefix) == VP_OK); + assert(value.used == prefix && value.generated == 0 && !value.ready && !value.failed); + assert(next.observed_prefix == prefix && next.resets == 1); + } +} + +void invalid_retention_poisoned_without_backend_work() { + for (unsigned variant = 0; variant < 5; ++variant) { + auto value = previous(); + uint32_t prefix = 17, capacity = 13312; + if (variant == 1) { prefix = 16; capacity = 16; } + if (variant == 2) { prefix = 12288; value.used = 12288; } + if (variant == 3) { prefix = 0; capacity = 15; } + if (variant == 4) { prefix = 0; value.failed = true; } + backend next; + assert(next.begin(value, prefix, capacity) == VP_INVALID); + assert(value.failed && !value.ready); + assert(next.checks == 0 && next.synchronized == 0 && next.truncated == 0 && next.resets == 0); + assert(next.begin(value, 0) == VP_INVALID); // No cold restart after refusal. + } +} + +void truncation_failure_never_resets_or_reuses() { + auto value = previous(); + backend next; + next.truncate_ok = false; + assert(next.begin(value, 8) == VP_BACKEND); + assert(value.failed && !value.ready && value.used == 16 && value.generated == 4); + assert(next.truncated == 1 && next.resets == 0); + next.truncate_ok = true; + assert(next.begin(value, 0) == VP_INVALID); + assert(next.truncated == 1 && next.resets == 0); +} + +void cancellation_at_every_backend_boundary() { + for (unsigned cancel = 0; cancel < 4; ++cancel) { + auto value = previous(); + backend next; + next.cancel_at = cancel; + assert(next.begin(value, 8) == VP_ABORTED); + assert(value.failed && !value.ready && value.used == 16 && value.generated == 4); + assert(next.synchronized == (cancel >= 1 ? 1u : 0u)); + assert(next.truncated == (cancel >= 2 ? 1u : 0u)); + assert(next.resets == (cancel >= 3 ? 1u : 0u)); + } +} + +void exceptions_never_cross_the_turn_boundary() { + for (unsigned stage = 0; stage < 3; ++stage) { + auto value = previous(); + backend next; + next.throw_at = stage; + assert(next.begin(value, 8) == VP_BACKEND); + assert(value.failed && !value.ready); + } + auto value = previous(); + assert(vp::begin_session_turn(value, 8, 13312, + []() -> bool { throw std::runtime_error("synthetic callback"); }, + [] { assert(false); }, [](uint32_t) { assert(false); return true; }, + [] { assert(false); }) == VP_BACKEND); + assert(value.failed && !value.ready); +} + +void prompt_generation_and_legacy_boundaries() { + vp::session_state value; + value.used = 12288; + assert(vp::session_sample_allowed(value)); + value.used = 12289; + assert(!vp::session_sample_allowed(value)); + value.generated = 1; + assert(vp::session_sample_allowed(value)); + value.generated = 1023; + assert(vp::session_sample_allowed(value)); + value.generated = 1024; + assert(!vp::session_sample_allowed(value)); + value.ready = true; + assert(vp::invalid_session(&value) == VP_INVALID); + assert(!value.failed && value.ready); // Unrequested legacy behavior unchanged. + value.session_active = true; + assert(vp::invalid_session(&value) == VP_INVALID); + assert(value.failed && !value.ready); + assert(vp::invalid_session(nullptr) == VP_INVALID); +} + +struct growing_backend { + uint32_t capacity = 1280; + unsigned checks = 0, synchronized = 0, destroyed = 0, created = 0; + unsigned cancel_at = 99, throw_at = 99, live_contexts = 1; + bool create_ok = true; + + int32_t grow(vp::session_state & value, uint32_t requested = 1536) { + return vp::grow_session_context(value, capacity, requested, + [&] { assert(!value.ready); return checks++ == cancel_at; }, + [&] { + assert(live_contexts == 1); + ++synchronized; + if (throw_at == 0) { throw std::runtime_error("synthetic sync"); } + }, + [&] { + assert(synchronized == 1 && live_contexts == 1); + ++destroyed; + if (throw_at == 1) { throw std::runtime_error("synthetic destroy"); } + live_contexts = 0; + }, + [&](uint32_t next) { + assert(destroyed == 1 && live_contexts == 0 && next == requested); + ++created; + if (throw_at == 2) { throw std::runtime_error("synthetic create"); } + if (create_ok) { live_contexts = 1; } + return create_ok; + }); + } +}; + +void growth_destroys_before_create_and_forgets_all_old_positions() { + auto value = previous(); + growing_backend next; + assert(next.grow(value) == VP_OK); + assert(next.capacity == 1536 && next.live_contexts == 1); + assert(next.synchronized == 1 && next.destroyed == 1 && next.created == 1 && next.checks == 4); + assert(value.used == 0 && value.generated == 0 && !value.ready && !value.failed && value.session_active); + backend turn; + assert(turn.begin(value, 0, next.capacity) == VP_OK); + assert(value.used == 0 && !value.ready); // A complete new prompt must still be decoded. + auto cannot_retain = previous(); + growing_backend other; + assert(other.grow(cannot_retain) == VP_OK); + assert(turn.begin(cannot_retain, 1, other.capacity) == VP_INVALID); +} + +void growth_rejects_invalid_state_capacity_and_preserves_maximum() { + for (unsigned variant = 0; variant < 6; ++variant) { + auto value = previous(); + growing_backend next; + uint32_t requested = 1280; + if (variant == 1) { requested = 1279; } + if (variant == 2) { requested = 13313; } + if (variant == 3) { requested = 1536; value.failed = true; } + if (variant == 4) { requested = 1536; value.session_active = false; } + if (variant == 5) { requested = 1536; value.used = 1281; } + assert(next.grow(value, requested) == VP_INVALID); + assert(value.failed && !value.ready); + assert(next.capacity == 1280 && next.checks == 0 && next.destroyed == 0 && next.created == 0); + } + auto value = previous(); + growing_backend maximum; + assert(maximum.grow(value, 13312) == VP_OK && maximum.capacity == 13312); +} + +void growth_cancellation_never_commits_capacity_or_reuses_partial_state() { + for (unsigned cancel = 0; cancel < 4; ++cancel) { + auto value = previous(); + growing_backend next; + next.cancel_at = cancel; + assert(next.grow(value) == VP_ABORTED); + assert(value.failed && !value.ready && next.capacity == 1280); + assert(next.synchronized == (cancel >= 1 ? 1u : 0u)); + assert(next.destroyed == (cancel >= 2 ? 1u : 0u)); + assert(next.created == (cancel >= 3 ? 1u : 0u)); + const auto checked = next.checks; + assert(next.grow(value) == VP_INVALID && next.checks == checked); + } +} + +void growth_allocation_failure_or_exception_poison_without_second_context() { + auto value = previous(); + growing_backend failed; + failed.create_ok = false; + assert(failed.grow(value) == VP_BACKEND); + assert(value.failed && !value.ready && failed.live_contexts == 0 && failed.capacity == 1280); + assert(failed.grow(value) == VP_INVALID && failed.created == 1); + for (unsigned stage = 0; stage < 3; ++stage) { + auto subject = previous(); + growing_backend next; + next.throw_at = stage; + assert(next.grow(subject) == VP_BACKEND); + assert(subject.failed && !subject.ready && next.capacity == 1280); + assert(next.live_contexts <= 1); + } +} +} + +int main() { + fresh_and_retained_turns(); + invalid_retention_poisoned_without_backend_work(); + truncation_failure_never_resets_or_reuses(); + cancellation_at_every_backend_boundary(); + exceptions_never_cross_the_turn_boundary(); + prompt_generation_and_legacy_boundaries(); + growth_destroys_before_create_and_forgets_all_old_positions(); + growth_rejects_invalid_state_capacity_and_preserves_maximum(); + growth_cancellation_never_commits_capacity_or_reuses_partial_state(); + growth_allocation_failure_or_exception_poison_without_second_context(); +} diff --git a/workers/volparossa-ml/native_session.py b/workers/volparossa-ml/native_session.py new file mode 100644 index 000000000..ca05b1476 --- /dev/null +++ b/workers/volparossa-ml/native_session.py @@ -0,0 +1,151 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Connection-owned RAM lifetime, selected only by the fixed core supervisor. + +No listener, session token, file-cache export or cross-owner lookup exists here. +Each turn still performs the full original source/GGUF/input validation. The +parent independently owns admission, pressure, deadlines and joined cleanup. +""" +import contextlib +import math +import os +import resource +import select +import sys +import time + +MAX_TASK_SECONDS = 2400 +MAX_IDLE_SECONDS = 300 +MAX_REQUESTS = 128 + + +class Runtime: + def __init__(self, worker): + self.worker = worker + native = sys.modules.get("volparossa_llama_cpu") + worker.require(native is not None, "NATIVE_BACKEND_MODULE_UNAVAILABLE") + self.model = native.RetainedModel() + self.loaded, self.loaded_tokenizer, self.threads = None, None, None + + def backend(self, threads, session): + self.worker.require(self.threads is None or self.threads == threads, "NATIVE_SESSION_MODEL_CHANGED") + if self.loaded is None: + self.loaded = self.worker.load_backend(threads, session) + self.threads = threads + session.check() + return self.loaded + + def tokenizer(self, transformers, root): + if self.loaded_tokenizer is None: + self.loaded_tokenizer = transformers.AutoTokenizer.from_pretrained( + str(root), local_files_only=True, trust_remote_code=False, use_fast=True) + return self.loaded_tokenizer + + def close(self): + try: + self.model.close() + finally: + self.loaded_tokenizer, self.loaded = None, None + + +def turn_cpu_limit(request, require): + """New lifetime only: retain the old per-turn allowance and fixed task hard cap. + + SIGXCPU keeps its default terminating disposition. Parent /proc observation + checks the same turn delta; neither path guesses unknown CPU consumption. + """ + usage = resource.getrusage(resource.RUSAGE_SELF) + consumed = usage.ru_utime + usage.ru_stime + require(math.isfinite(consumed) and consumed >= 0, "NATIVE_SESSION_CPU_OBSERVATION") + hard = MAX_TASK_SECONDS * request["threads"] + _soft, actual_hard = resource.getrlimit(resource.RLIMIT_CPU) + require(actual_hard == hard and consumed < hard, "NATIVE_SESSION_CPU_BOUND") + allowance = request["max_seconds"] * request["threads"] + resource.setrlimit(resource.RLIMIT_CPU, (min(hard, math.ceil(consumed) + allowance), hard)) + + +def read_frame(frames, deadline, worker, *, eof=False): + while True: + worker.require(not worker.is_stopping(), "JOB_CANCELLED") + worker.require(time.monotonic() < deadline, "NATIVE_SESSION_DEADLINE") + raw = frames.take(worker.MAX_REQUEST, "INVALID_REQUEST_FRAME") + if raw is not None: + return worker.parse_json(raw) + if not select.select([frames.descriptor], [], [], min(0.05, max(0, deadline - time.monotonic())))[0]: + continue + try: + block = os.read(frames.descriptor, min(4096, worker.MAX_REQUEST - len(frames.pending))) + except BlockingIOError: + continue + if not block: + worker.require(eof and not frames.pending, "OWNER_CONTROL_CLOSED") + return None + frames.pending.extend(block) + + +def settle_turn(session, deadline, worker): + # Controls written just before result observation belong to the old request, + # never to the next turn. No further model work happens in this handshake. + while True: + value = read_frame(session.frames, deadline, worker) + if value == {"version": 1, "id": session.request["id"], "kind": "native_turn_finish"}: + worker.require(type(value["version"]) is int, "INVALID_NATIVE_SESSION_FINISH") + break + raw = worker.json.dumps(value, ensure_ascii=True, separators=(",", ":")).encode("ascii") + b"\n" + worker.require(len(raw) <= worker.MAX_CONTROL_LINE, "INVALID_CONTROL_FRAME") + session.accept_control(raw) + worker.emit({"version": 1, "id": session.request["id"], "kind": "native_turn_idle"}) + + +def run(first, frames, worker): + runtime, session, request = Runtime(worker), None, first + started, turn, seen = time.monotonic(), 0, set() + binding = tuple(first[key] for key in ("model_profile", "inference_backend", "native_backend_sha256", "threads", "max_seconds")) + try: + while request is not None: + turn += 1 + worker.require(turn <= MAX_REQUESTS and request["native_session"]["turn"] == turn + and request["id"] not in seen, "INVALID_NATIVE_SESSION_SEQUENCE") + seen.add(request["id"]) + worker.require(binding == tuple(request[key] for key in + ("model_profile", "inference_backend", "native_backend_sha256", "threads", "max_seconds")), + "NATIVE_SESSION_MODEL_CHANGED") + worker.require(time.monotonic() < started + MAX_TASK_SECONDS, "NATIVE_SESSION_DEADLINE") + turn_cpu_limit(request, worker.require) + session = worker.Session(request, frames) + with open(os.devnull, "w", encoding="ascii") as quiet: + with contextlib.redirect_stdout(quiet), contextlib.redirect_stderr(quiet): + result = worker.execute_job(request, session, native_session=runtime) + worker.emit(result) + settle_turn(session, min(started + MAX_TASK_SECONDS, session.started + request["max_seconds"]), worker) + # Drop the complete request/result; retained private tokens/KV have a + # separate explicit lifetime. This does not claim secure RAM erasure. + session, result = None, None + # The parent owns the exact 300-second idle boundary and closes this + # pipe. A second independently started idle clock must not race its + # joined close into an erroneous failed turn. The task backstop is + # still finite here and the parent's earlier deadline always wins. + value = read_frame(frames, started + MAX_TASK_SECONDS, worker, eof=True) + request = None if value is None else worker.validate_request(value) + worker.require(request is None or "native_session" in request, "INVALID_NATIVE_SESSION") + return 0 + except worker.JobError as error: + code = str(error) + except (FileNotFoundError, NotADirectoryError): + code = "JOB_INPUT_NOT_FOUND" + except PermissionError: + code = "JOB_PATH_PERMISSION_DENIED" + except MemoryError: + code = "JOB_MEMORY_EXHAUSTED" + except ImportError: + code = "BACKEND_IMPORT_FAILED" + except Exception: + code = "BACKEND_EXECUTION_FAILED" + finally: + runtime.close() + failure = {"version": 1, "id": request["id"] if request is not None else first["id"], + "kind": "result", "status": "error", "code": code, + "elapsed_ms": session.elapsed() if session is not None else 0} + if session is not None: + failure["owner_control"] = session.owner_stats() + worker.emit(failure) + return 1 diff --git a/workers/volparossa-ml/provision.py b/workers/volparossa-ml/provision.py index cac481503..aaae918e6 100644 --- a/workers/volparossa-ml/provision.py +++ b/workers/volparossa-ml/provision.py @@ -51,6 +51,7 @@ GRAPH_DECODER = {"implementation": "lm-format-enforcer", "version": "0.11.3", "adapter_version": 1, "schema_version": 3, "dependencies": {"interegular": "0.3.3", "pydantic": "1.10.24"}} +NATIVE_CONVERTER = {"implementation": "llama_cpp_bf16_v1", "dependencies": {"sentencepiece": "0.2.1"}} RESERVE_BYTES = 64 * 1024 * 1024 ALLOWED_HOSTS = frozenset({ "files.pythonhosted.org", "download.pytorch.org", "download-r2.pytorch.org", @@ -83,8 +84,10 @@ def redirect_request(self, request, fp, code, msg, headers, newurl): return super().redirect_request(request, fp, code, msg, headers, newurl) -def load_pins(model_profile=DEFAULT_MODEL_PROFILE, task_graph_decoder=False): +def load_pins(model_profile=DEFAULT_MODEL_PROFILE, task_graph_decoder=False, native_cpu_converter=False): require(model_profile in PROFILES, "unsupported model profile") + require(not native_cpu_converter or (model_profile == QWEN4B_MODEL_PROFILE and not task_graph_decoder), + "native converter requires the explicit 4B profile without graph decoder") model_id, revision = PROFILES[model_profile] pins = json.loads((HERE / "model-pins.json").read_text()) if model_profile in PROFILE_PINS: @@ -144,6 +147,8 @@ def load_pins(model_profile=DEFAULT_MODEL_PROFILE, task_graph_decoder=False): require(actual == expected, "requirements.lock does not exactly match artifact pins") if task_graph_decoder: add_graph_decoder(pins) + if native_cpu_converter: + add_native_converter(pins) return pins @@ -180,15 +185,60 @@ def add_graph_decoder(pins): pins["task_graph_decoder"] = extra["decoder"] +def native_converter_pins(): + extra = json.loads((HERE / "native-converter-pins.json").read_text()) + require(extra["format_version"] == 1 and extra["platform"] == "cpython-3.13-linux-x86_64" + and extra["converter"] == NATIVE_CONVERTER and len(extra["wheels"]) == 1 + and extra["source_revision"] == "31646a467d2051eb904e0b45de3a73e91fe1c1e3" + and extra["wheel_source_build_claimed"] is False, "unsupported native converter pins") + item = extra["wheels"][0] + name = "sentencepiece-0.2.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl" + require(item["name"] == "sentencepiece" and item["version"] == "0.2.1" and item["path"] == name + and item["bytes"] == 1387882 + and item["sha256"] == "c7f0fd2f2693309e6628aeeb2e2faf6edd221134dfccac3308ca0de01f8dab47" + and item["metadata_sha256"] == "ce0a2e9c599cd060a7bc3dffb18be5cfc3d5f293533e3ee4e870eaef0a5500fd" + and item["license"] == "Apache-2.0", "native converter wheel identity differs") + expected_url = "https://files.pythonhosted.org/packages/96/df/0cfe748ace5485be740fed9476dee7877f109da32ed0d280312c94ec259f/" + name + require(official_url(item["url"]) == expected_url, "native converter wheel origin differs") + lock = (HERE / "native-converter-requirements.lock").read_text() + require([line for line in lock.splitlines() if line and not line.startswith("#")] + == [f"{item['name']}=={item['version']} --hash=sha256:{item['sha256']}"], + "native converter lock differs from pins") + expected_notices = {"LICENSE", "third_party/absl/LICENSE", "third_party/darts_clone/LICENSE", + "third_party/esaxx/LICENSE", "third_party/protobuf-lite/LICENSE"} + require(len(extra["notices"]) == 5 and {notice["source"] for notice in extra["notices"]} == expected_notices, + "native converter original notices differ") + for notice in extra["notices"]: + local = "native-cpu/sentencepiece" + ("" if notice["source"] == "LICENSE" else "-" + notice["source"].split("/")[1]) + "-LICENSE" + require(notice["path"] == local and notice["url"] == "https://raw.githubusercontent.com/google/sentencepiece/" + + extra["source_revision"] + "/" + notice["source"], "native converter notice provenance differs") + raw = (HERE / local).read_bytes() + require(len(raw) == notice["bytes"] and hashlib.sha256(raw).hexdigest() == notice["sha256"], + "native converter original notice bytes differ") + return extra + + +def add_native_converter(pins): + extra = native_converter_pins() + item = extra["wheels"][0] + require(not any(row["name"] == item["name"] or row["path"] == item["path"] for row in pins["wheels"]), + "native converter cannot override a pinned distribution") + pins["wheels"] += extra["wheels"] + pins["native_cpu_converter"] = extra["converter"] + + def retained_pin_files(pins): """Return exact retained bytes, including the original baseline without the opt-in.""" - enabled = "task_graph_decoder" in pins + enabled = "task_graph_decoder" in pins or "native_cpu_converter" in pins pin_bytes = ((HERE / "model-pins.json").read_bytes() if pins["model_id"] == MODEL_ID and not enabled else (json.dumps(pins, indent=2) + "\n").encode()) requirements = (HERE / "requirements.lock").read_bytes() - if enabled: + if "task_graph_decoder" in pins: requirements += (b"" if requirements.endswith(b"\n") else b"\n") requirements += (HERE / "graph-decoder-requirements.lock").read_bytes() + if "native_cpu_converter" in pins: + requirements += (b"" if requirements.endswith(b"\n") else b"\n") + requirements += (HERE / "native-converter-requirements.lock").read_bytes() return pin_bytes, requirements @@ -388,7 +438,45 @@ def run_checked(command, environment, root, deadline): subprocess.run(command, env=environment, cwd=root, check=True, timeout=remaining) +def native_cpu_options(args): + source = getattr(args, "native_cpu_source", None) + build = getattr(args, "native_cpu_build", None) + if source is None and build is None: + return None + require(source is not None and build is not None and args.model_profile == QWEN4B_MODEL_PROFILE, + "native CPU conversion requires both local source/build paths and the exact 4B profile") + require(all(Path(path).is_absolute() for path in (source, build)), "native CPU input paths must be absolute") + return {"source": str(source), "build": str(build), "kind": "llama_cpp_bf16_v1"} + + +def provision_native_cpu(args, root, python, environment, deadline, used_bytes): + selection = native_cpu_options(args) + if selection is None: + return None + remaining_budget = args.budget_bytes - used_bytes + require(remaining_budget >= QWEN4B_WEIGHTS["bytes"] + 256 * 1024 * 1024, + "same provisioning budget cannot hold additional verified BF16 GGUF") + remaining_seconds = min(1800, int(deadline - time.monotonic())) + require(remaining_seconds >= 1, "native conversion has no remaining original provisioning deadline") + target = root / "native-backend" + # The existing pinned venv executes only the fixed local conversion tool. + # No model inference, package installation or extra download is added here. + run_checked([python, "-B", str(HERE / "convert_llama_cpu.py"), + "--source", selection["source"], "--build", selection["build"], + "--model", str(root / "model"), "--root", str(target), + "--budget-bytes", str(remaining_budget), "--timeout-seconds", str(remaining_seconds), + "--execute", "--yes", "--disposable-guest"], environment, root, deadline) + manifest = target / "backend.json" + info = manifest.lstat() + require(stat.S_ISREG(info.st_mode) and info.st_size <= 65536 and info.st_mode & 0o077 == 0, + "native conversion did not produce private bounded manifest") + return {"kind": selection["kind"], "root": str(target), + "backend_sha256": hashlib.sha256(manifest.read_bytes()).hexdigest()} + + def execute(args, pins): + require(("native_cpu_converter" in pins) == (native_cpu_options(args) is not None), + "native converter dependencies and explicit source/build selection differ") root, guest_kind = execution_root(args) total = download_total(pins) require(args.budget_bytes >= total + RESERVE_BYTES, "budget cannot hold pinned downloads") @@ -444,6 +532,13 @@ def execute(args, pins): "print('OFFLINE_CPU_RUNTIME_IMPORT_OK')"], environment, root, deadline) if "task_graph_decoder" in pins: run_checked([python, "-I", "-B", "-c", CHECK_GRAPH_DECODER], environment, root, deadline) + if "native_cpu_converter" in pins: + run_checked([python, "-I", "-B", "-c", "import sentencepiece;" + "assert sentencepiece.__version__=='0.2.1';" + "assert callable(sentencepiece.SentencePieceProcessor);" + "print('OFFLINE_NATIVE_CONVERTER_IMPORT_OK')"], environment, root, deadline) + native_backend = provision_native_cpu(args, root, python, environment, deadline, + total + expanded + RESERVE_BYTES) report = { "format_version": 1, "success": True, "guest": guest_kind, "runtime_root": str(runtime), "model_root": str(model), @@ -461,6 +556,10 @@ def execute(args, pins): report["weights"] = pins["weights"] if "task_graph_decoder" in pins: report["task_graph_decoder"] = pins["task_graph_decoder"] + if "native_cpu_converter" in pins: + report["native_cpu_converter"] = pins["native_cpu_converter"] + if native_backend is not None: + report["native_backend"] = native_backend (root / "provision-report.json").write_text(json.dumps(report, indent=2) + "\n") print(json.dumps(report), flush=True) except BaseException: @@ -483,10 +582,13 @@ def main(argv=None): parser.add_argument("--model-profile", choices=PROFILES, default=DEFAULT_MODEL_PROFILE) parser.add_argument("--task-graph-decoder", action="store_true", help="explicitly add the three pinned optional constrained-graph decoder wheels") + parser.add_argument("--native-cpu-source", help="explicit clean pinned local llama.cpp source for offline BF16 conversion") + parser.add_argument("--native-cpu-build", help="explicit local source-built CPU adapter; no automatic source download") args = parser.parse_args(argv) try: require(1 <= args.timeout_seconds <= 3600, "timeout must be 1..3600 seconds") - pins = load_pins(args.model_profile, args.task_graph_decoder) + native_backend = native_cpu_options(args) + pins = load_pins(args.model_profile, args.task_graph_decoder, native_backend is not None) plan = { "mode": "execute" if args.execute else "preview", "root": args.root, "model_id": pins["model_id"], "revision": pins["revision"], "wheel_count": len(pins["wheels"]), @@ -500,6 +602,9 @@ def main(argv=None): plan["model_profile"] = args.model_profile if args.task_graph_decoder: plan["task_graph_decoder"] = pins["task_graph_decoder"] + if native_backend is not None: + plan["native_backend"] = dict(native_backend, conversion="offline_exact_bf16_values", + shared_original_deadline=True, shared_original_disk_budget=True) print(json.dumps(plan, indent=2), flush=True) if args.execute: execute(args, pins) diff --git a/workers/volparossa-ml/test_conversion_diagnostic.py b/workers/volparossa-ml/test_conversion_diagnostic.py new file mode 100644 index 000000000..fc11d4629 --- /dev/null +++ b/workers/volparossa-ml/test_conversion_diagnostic.py @@ -0,0 +1,209 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Tiny inert diagnostic fixtures; no converter, model, source fetch or host service.""" +from contextlib import ExitStack, redirect_stderr, redirect_stdout +import hashlib +import io +import json +import os +from pathlib import Path +import signal +import subprocess +import sys +import tempfile +from types import SimpleNamespace +import unittest +from unittest import mock + +import convert_llama_cpu as conversion + + +class ConversionDiagnosticTests(unittest.TestCase): + def exercise_stage(self, rejected, build_version=2): + with tempfile.TemporaryDirectory() as directory, ExitStack() as stack: + base = Path(directory) + model, build, source = (base / name for name in ('model', 'build', 'source')) + for path in (model, build, source, build / 'licenses'): + path.mkdir(mode=0o700) + for path in (model / 'weights', model / 'LICENSE', build / conversion.native.LIBRARY): + path.write_bytes(b'inert') + pinned = [{'path': name, 'bytes': 5, 'sha256': hashlib.sha256(b'inert').hexdigest()} + for name in ('weights', 'LICENSE')] + manifest = dict(version=build_version, source_commit=conversion.builder.SOURCE, kind=conversion.native.KIND, + source_tree=conversion.native.SOURCE_TREE, source_overlay=conversion.native.loader_provenance(), + loader_compile_source_verified=True, upstream_original_unchanged=True, + abi_version=1, quantization=False, provisionable=True, sanitizers=False, + library=dict(path=conversion.native.LIBRARY, **conversion.builder.digest(build / conversion.native.LIBRARY)), + dynamic_dependencies=['libc.so.6'], + wrapper_sources={name: conversion.builder.digest(conversion.HERE / 'native-cpu' / name) + for name in conversion.builder.WRAPPER_FILES}) + if build_version == 3: + manifest['output_constraints'] = [conversion.native.OUTPUT_CONSTRAINT] + (build / 'build.json').write_text(json.dumps(manifest)) + progress, visited = {}, [] + def boundary(stage, result=None): + self.assertEqual(progress['stage'], stage) + visited.append(stage) + if stage == rejected: + raise ValueError('CANARY /private/model token') + return result + stack.enter_context(mock.patch.object(conversion.provision, 'execution_root', + side_effect=lambda _args: boundary('guard', (base / 'output', 'inert')))) + stack.enter_context(mock.patch.object(conversion.builder, 'verify_source', + side_effect=lambda _path: boundary('source', 'inert-tree'))) + # This suite checks closed diagnostic staging, not native compilation; + # real staged-source/hash checks have separate source-only coverage. + stack.enter_context(mock.patch.object(conversion.builder, 'verify_loader_compilation', + side_effect=lambda _root, _loader: boundary('build'))) + stack.enter_context(mock.patch.object(conversion.provision, 'load_pins', return_value={'files': pinned})) + versions = {'torch': '2.14.0+cpu', 'transformers': '5.16.1', 'peft': '0.20.0', 'sentencepiece': '0.2.1'} + stack.enter_context(mock.patch.object(conversion.importlib.metadata, 'version', + side_effect=lambda name: boundary('runtime', versions[name]))) + original_digest = conversion.builder.digest + def digest(path): + if path.parent == model: + boundary('model') + elif path == build / conversion.native.LIBRARY: + boundary('build') + return original_digest(path) + stack.enter_context(mock.patch.object(conversion.builder, 'digest', side_effect=digest)) + def weights(*_args): + if progress['stage'] == 'recheck': + boundary('recheck') + stack.enter_context(mock.patch.object(conversion.provision, 'verify_weight_set', side_effect=weights)) + stack.enter_context(mock.patch.object(conversion.shutil, 'disk_usage', + side_effect=lambda _path: boundary('budget', SimpleNamespace(free=12 * 1024**3)))) + def converter(_command, _log, _environment, _timeout, observed): + self.assertIs(observed, progress) + boundary('convert') + progress['child_exit_status'] = 0 + (base / 'output/model.gguf').write_bytes(b'inert') + stack.enter_context(mock.patch.object(conversion, 'run_converter', side_effect=converter)) + tokenizer = SimpleNamespace(AutoTokenizer=SimpleNamespace(from_pretrained= + lambda *_args, **_kwargs: boundary('tokenizer', object()))) + stack.enter_context(mock.patch.dict(sys.modules, {'gguf': SimpleNamespace(GGUFReader=lambda _path: object()), + 'transformers': tokenizer})) + stack.enter_context(mock.patch.object(conversion, 'verify_conversion', + side_effect=lambda *_args: boundary('verify', {}))) + original_copy = conversion.shutil.copyfile + def copyfile(src, dst): + boundary('bundle') + return original_copy(src, dst) + stack.enter_context(mock.patch.object(conversion.shutil, 'copyfile', side_effect=copyfile)) + stack.enter_context(mock.patch.object(conversion.provision, 'native_converter_pins', return_value={'notices': []})) + def converted_manifest(value, _require): + self.assertEqual(value['version'], build_version - 1) + if build_version == 3: + self.assertEqual(value['output_constraints'], [conversion.native.OUTPUT_CONSTRAINT]) + else: + self.assertNotIn('output_constraints', value) + boundary('manifest') + stack.enter_context(mock.patch.object(conversion.native, 'validate_manifest', + side_effect=converted_manifest)) + stack.enter_context(mock.patch.object(conversion, 'private_bundle_modes', + side_effect=lambda _path: boundary('permissions'))) + args = SimpleNamespace(root=str(base / 'output'), source=source, build=build, model=model, + budget_bytes=10 * 1024**3, timeout_seconds=30) + try: + conversion.execute(args, progress) + except ValueError as error: + self.assertEqual(str(error), 'CANARY /private/model token') + return progress, conversion.failure_diagnostic(progress, error), visited + self.fail('inert stage refusal must not become success') + + def test_actual_pipeline_records_each_failure_stage_without_model_execution(self): + stages = ['guard', 'source', 'runtime', 'model', 'build', 'budget', 'convert', + 'tokenizer', 'verify', 'recheck', 'bundle', 'manifest', 'permissions'] + original_path = list(sys.path) + try: + for stage in stages: + with self.subTest(stage=stage): + progress, diagnostic, visited = self.exercise_stage(stage) + self.assertEqual(progress['stage'], stage) + self.assertEqual(visited[-1], stage) + self.assertEqual(diagnostic['stage'], stage) + self.assertEqual(diagnostic['failure'], 'contract') + self.assertNotIn('CANARY', json.dumps(diagnostic)) + self.assertNotIn('/private', json.dumps(diagnostic)) + finally: + sys.path[:] = original_path + + def test_new_build_capability_projects_into_new_backend_schema_only(self): + original_path = list(sys.path) + try: + progress, diagnostic, visited = self.exercise_stage('permissions', build_version=3) + self.assertEqual(progress['stage'], 'permissions') + self.assertIn('manifest', visited) + self.assertEqual(diagnostic['failure'], 'contract') + finally: + sys.path[:] = original_path + + def test_child_nonzero_timeout_and_cancellation_retain_bounded_final_status(self): + signals = (signal.SIGTERM, signal.SIGINT, signal.SIGHUP) + before = {item: signal.getsignal(item) for item in signals} + for failure in ('nonzero', 'timeout', 'cancelled'): + with self.subTest(failure=failure), ExitStack() as stack: + progress = {'stage': 'convert'} + child = mock.Mock(pid=os.getpid()) + child.wait.return_value = child.poll.return_value = 7 + expected, status = 'child_failed', 7 + if failure == 'timeout': + stack.enter_context(mock.patch.object(conversion.time, 'monotonic', side_effect=[0, 2])) + child.poll.side_effect = [None, -15] + child.wait.return_value = -15 + expected, status = 'timeout', -15 + elif failure == 'cancelled': + def cancel(**_kwargs): + signal.getsignal(signal.SIGTERM)(signal.SIGTERM, None) + return 0 + child.wait.side_effect = cancel + child.poll.return_value = 0 + expected, status = 'cancelled', 0 + stack.enter_context(mock.patch.object(conversion.subprocess, 'Popen', return_value=child)) + with self.assertRaises(Exception) as result: + conversion.run_converter(['inert'], io.BytesIO(), {}, 1, progress) + diagnostic = conversion.failure_diagnostic(progress, result.exception) + self.assertEqual((diagnostic['failure'], diagnostic['child_exit_status']), (expected, status)) + self.assertEqual({item: signal.getsignal(item) for item in signals}, before) + + def test_main_emits_one_closed_failure_and_keeps_legacy_marker(self): + argv = ['convert', '--source', '/source', '--build', '/build', '--model', '/model', + '--root', '/output', '--budget-bytes', '1', '--execute', '--yes', '--disposable-guest'] + def fail(_args, progress): + progress.update(stage='verify', child_exit_status=0) + raise ValueError('CANARY /private/model') + out, err = io.StringIO(), io.StringIO() + with mock.patch.object(sys, 'argv', argv), mock.patch.object(conversion, 'execute', side_effect=fail), \ + redirect_stdout(out), redirect_stderr(err), self.assertRaises(SystemExit) as stopped: + conversion.main() + self.assertEqual(stopped.exception.code, 1) + self.assertEqual(out.getvalue(), '') + lines = err.getvalue().splitlines() + self.assertEqual(len(lines), 2) + self.assertEqual(lines[1], 'NATIVE_CONVERSION_FAILED') + self.assertTrue(lines[0].startswith(conversion.DIAGNOSTIC_PREFIX)) + value = json.loads(lines[0][len(conversion.DIAGNOSTIC_PREFIX):]) + self.assertEqual(value, dict(version=1, kind='native-conversion-failure', stage='verify', + failure='contract', child_exit_status=0)) + self.assertNotIn('CANARY', err.getvalue()) + self.assertNotIn('/private', err.getvalue()) + + def test_failures_and_unknown_values_never_export_exception_text(self): + cases = [(FileNotFoundError('CANARY'), 'missing_file'), (PermissionError('CANARY'), 'permission'), + (MemoryError('CANARY'), 'memory'), (ImportError('CANARY'), 'import'), + (subprocess.CalledProcessError(7, ['CANARY']), 'subprocess'), (OSError('CANARY'), 'os_error'), + (KeyError('CANARY'), 'invalid_shape'), (TypeError('CANARY'), 'invalid_shape'), + (ValueError('NATIVE_CONVERSION_DISK_BUDGET'), 'disk_budget'), + (conversion.provision.ProvisionError('CANARY'), 'contract'), (RuntimeError('CANARY'), 'unknown')] + for error, expected in cases: + with self.subTest(expected=expected): + value = conversion.failure_diagnostic({'stage': ['CANARY'], 'child_exit_status': True}, error) + self.assertEqual(value['failure'], expected) + self.assertEqual(value['stage'], 'unknown') + self.assertIsNone(value['child_exit_status']) + self.assertNotIn('CANARY', json.dumps(value)) + for status in (-129, 256, True, '0'): + self.assertIsNone(conversion.child_status(status)) + + +if __name__ == '__main__': + unittest.main() diff --git a/workers/volparossa-ml/test_grounded_synthesis.py b/workers/volparossa-ml/test_grounded_synthesis.py index e4b8791a4..abf753ef7 100644 --- a/workers/volparossa-ml/test_grounded_synthesis.py +++ b/workers/volparossa-ml/test_grounded_synthesis.py @@ -162,7 +162,8 @@ def test_real_prepare_files_identity_binds_v5_and_planning_original_source_bytes (model / name).touch(mode=0o600) (model / "config.json").write_text(json.dumps(profile["config"])) # File/hash doubles exercise worker identity logic, never model data or load. - def file_hash(path, expected_size=None): + def file_hash(path, expected_size=None, *, session=None): + self.assertIsNone(session) self.assertEqual(expected_size, profile["files"][path.name]) return dict(bytes=expected_size, sha256=profile["hashes"][path.name]) for mode, value in (("infer", grounded()), ("plan_document", planning(grounded()))): diff --git a/workers/volparossa-ml/test_llama_cpu.py b/workers/volparossa-ml/test_llama_cpu.py new file mode 100644 index 000000000..664a8638a --- /dev/null +++ b/workers/volparossa-ml/test_llama_cpu.py @@ -0,0 +1,912 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Inert protocol/value-preservation tests: no model/backend download or inference.""" +import copy +import io +import json +import hashlib +import os +from pathlib import Path +import signal +import struct +import stat +import subprocess +import sys +import tempfile +import threading +import time +from types import SimpleNamespace +import unittest +from unittest import mock + +import build_llama_cpu as builder +import convert_llama_cpu as conversion +import llama_cpu as native +from test_worker_protocol import WORKER, request, controlled_pipe, control, dataset +from test_qwen4b_weights import synthetic_shards +from test_conversation import conversation + + +def job(): + return dict(request(), mode="private_conversation", model_profile=native.PROFILE, + steps=1, owner_control=True, inference_backend=native.KIND, + native_backend_root="/native-backend", native_backend_sha256="a" * 64) + + +def manifest(): + return {"version": 1, "kind": native.KIND, "abi_version": 1, "source_commit": native.SOURCE, + "model_profile": native.PROFILE, "source_weights_sha256": native.WEIGHTS_SHA, + "source_weights_bytes": native.WEIGHTS_BYTES, + "library": {"path": native.LIBRARY, "sha256": "b" * 64, "bytes": 10}, + "gguf": {"path": "model.gguf", "sha256": "c" * 64, "bytes": 20}, + "verification": {"tensor_count": 398, "tensor_values_equal": True, "tokenizer_equal": True, + "chat_template_sha256": "d" * 64}, "build_manifest_sha256": "e" * 64} + + +def source_fixture(root): + model, output = root / "source", root / "output" + model.mkdir(mode=0o700) + output.mkdir(mode=0o700) + profile, _ = synthetic_shards(model) + config = b"{}" + (model / "config.json").write_bytes(config) + profile.update(config={}, new_tokens=1, id="inert-source", revision="inert-revision") + profile["files"]["config.json"] = len(config) + profile["hashes"]["config.json"] = hashlib.sha256(config).hexdigest() + data = root / "input.json" + data.write_text(json.dumps(dict(conversation(), generation_policy="greedy_v1"))) + selected = WORKER.validate_request(dict(job(), model_root=str(model), output_root=str(output), + dataset_path=str(data))) + return selected, profile + + +class NativeSourceProvenanceTests(unittest.TestCase): + def test_initial_measurement_is_retained_without_changing_prepared_tuple(self): + with tempfile.TemporaryDirectory() as directory: + selected, profile = source_fixture(Path(directory)) + measured, collector = [], {} + original = WORKER.verify_sharded_weights + + def verify(*args, **kwargs): + result = original(*args, **kwargs) + measured.append(result) + # Distinguish carrying the completed measurement from silently + # copying the mutable expected profile after it was verified. + profile["weights"]["sha256"] = "0" * 64 + return result + + with mock.patch.object(WORKER, "model_profile", return_value=profile), \ + mock.patch.object(WORKER, "verify_sharded_weights", side_effect=verify): + prepared = WORKER.prepare_files(selected, initial_source=collector) + self.assertEqual(len(prepared), 5) + self.assertEqual(len(measured), 1) + self.assertEqual(collector, {"weights": measured[0]}) + self.assertIsNot(collector["weights"], measured[0]) + self.assertIsNot(collector["weights"]["files"], measured[0]["files"]) + self.assertNotEqual(collector["weights"], profile["weights"]) + self.assertEqual(collector["weights"]["sha256"], hashlib.sha256( + b"".join((prepared[0] / name).read_bytes() for name in profile["weight_shards"])).hexdigest()) + profile["weights"] = copy.deepcopy(measured[0]) + damaged = prepared[0] / profile["weight_shards"][-1] + damaged.write_bytes(b"x" * damaged.stat().st_size) + rejected = {} + with self.assertRaisesRegex(WORKER.JobError, "MODEL_WEIGHTS_CHANGED_ON_DISK"): + WORKER.prepare_files(selected, initial_source=rejected) + self.assertEqual(rejected, {}, "failed initial verification cannot create provenance") + + def test_collector_is_private_native_only_and_must_start_empty(self): + for change, collector in (({}, []), ({}, {"weights": {}}), + ({"inference_backend": None}, {}), + ({"model_profile": WORKER.QWEN_MODEL_PROFILE}, {}), + ({"mode": "private_infer"}, {})): + with self.subTest(change=change, collector=collector), \ + mock.patch.object(WORKER, "plain_path", side_effect=AssertionError("must fail before file access")), \ + self.assertRaisesRegex(WORKER.JobError, "NATIVE_SOURCE_PROVENANCE"): + WORKER.prepare_files(dict(job(), **change), initial_source=collector) + + def test_actual_worker_dispatch_forwards_the_measured_source_identity(self): + with tempfile.TemporaryDirectory() as directory: + selected, profile = source_fixture(Path(directory)) + tokenizer = SimpleNamespace(pad_token_id=151643, eos_token_id=151645) + transformers = SimpleNamespace(AutoTokenizer=SimpleNamespace(from_pretrained=lambda *a, **k: tokenizer)) + backend = mock.Mock() + backend.execute.return_value = "inert native dispatch" + original = WORKER.verify_sharded_weights + measured = [] + + def verify(*args, **kwargs): + value = original(*args, **kwargs) + measured.append(value) + return value + + with mock.patch.object(WORKER, "model_profile", return_value=profile), \ + mock.patch.object(WORKER, "verify_sharded_weights", side_effect=verify), \ + mock.patch.object(WORKER, "configure_offline"), \ + mock.patch.object(WORKER, "load_backend", return_value=(mock.Mock(), transformers, mock.Mock(), {})), \ + mock.patch.dict(WORKER.sys.modules, {"volparossa_llama_cpu": backend}): + self.assertEqual(WORKER.execute_job(selected, mock.Mock()), "inert native dispatch") + self.assertEqual(len(measured), 1) + self.assertEqual(backend.execute.call_args.kwargs, {"initial_source_weights": measured[0]}) + + +class HashOwnerControlTests(unittest.TestCase): + def hash_with_control(self, action): + """Real files and owner pipes; never a model or a resource-pressure trial.""" + block = 1024 * 1024 + raw = b"a" * block + b"b" * block + b"c" * 17 + paused, expired = threading.Event(), threading.Event() + reads, results, errors = [], [], [] + reading = False + with tempfile.TemporaryDirectory() as directory, controlled_pipe() as (session, writer, wire): + path = Path(directory) / "inert-weights" + path.write_bytes(raw) + os.write(writer, control(1)) + session.check() + session.request["max_seconds"] = 1 + original_start = session.started + aggregate = hashlib.sha256(b"prefix") + original_open, original_emit = WORKER.os.fdopen, WORKER.emit + + class ObservedReader: + def __init__(self, descriptor, mode): + self.source = original_open(descriptor, mode) + + def __enter__(self): + return self + + def __exit__(self, *args): + return self.source.__exit__(*args) + + def fileno(self): + return self.source.fileno() + + def read(self, size): + nonlocal reading + self_test.assertEqual(size, block) + reading = True + try: + data = self.source.read(size) + finally: + reading = False + reads.append(len(data)) + if len(reads) == 1: + os.write(writer, control(2, "cancel" if action == "cancel" else "pause")) + return data + + self_test = self + + def emit(record): + self.assertFalse(reading, "no ACK while a read is executing") + original_emit(record) + if record.get("phase") == "paused": + paused.set() + + def hash_file(): + try: + results.append(WORKER.file_hash(path, expected_size=len(raw), aggregate=aggregate, + session=session)) + except BaseException as error: + errors.append(error) + + with mock.patch.object(WORKER.os, "fdopen", ObservedReader), \ + mock.patch.object(WORKER, "emit", emit), \ + mock.patch.object(session, "elapsed", side_effect=lambda: 1001 if expired.is_set() else 0): + thread = threading.Thread(target=hash_file) + thread.start() + try: + if action != "cancel": + self.assertTrue(paused.wait(3), "missing pause ACK between hash reads") + self.assertTrue(thread.is_alive()) + self.assertEqual(reads, [block], "paused hashing must not read another chunk") + self.assertEqual(results, []) + if action == "resume": + os.write(writer, control(3, "resume")) + else: + expired.set() + thread.join(3) + self.assertFalse(thread.is_alive(), "hash worker did not finish bounded control") + finally: + if thread.is_alive(): + os.write(writer, control(session.sequence + 1, "cancel")) + thread.join(3) + self.assertEqual(session.started, original_start, "pause must not reset the deadline") + records = [json.loads(line) for line in wire.getvalue().splitlines()] + if action == "resume": + self.assertEqual(errors, []) + self.assertEqual(results, [{"bytes": len(raw), "sha256": hashlib.sha256(raw).hexdigest()}]) + self.assertEqual(aggregate.hexdigest(), hashlib.sha256(b"prefix" + raw).hexdigest()) + self.assertEqual(reads, [block, block, 17, 0]) + self.assertEqual([row["phase"] for row in records], ["resumed", "paused", "resumed"]) + else: + self.assertEqual(results, []) + self.assertEqual(reads, [block]) + self.assertEqual(len(errors), 1) + self.assertIsInstance(errors[0], WORKER.JobError) + self.assertEqual(str(errors[0]), "JOB_CANCELLED" if action == "cancel" else "JOB_DEADLINE_EXCEEDED") + + def test_hash_pause_resume_preserves_every_byte_and_aggregate(self): + self.hash_with_control("resume") + + def test_hash_cancel_does_not_return_a_partial_digest(self): + self.hash_with_control("cancel") + + def test_hash_pause_does_not_extend_original_deadline(self): + self.hash_with_control("deadline") + + def test_prepare_and_shards_forward_session_and_still_reject_tampering(self): + with tempfile.TemporaryDirectory() as directory, controlled_pipe() as (session, writer, _): + root = Path(directory) + model, output = root / "model", root / "output" + model.mkdir(mode=0o700) + output.mkdir(mode=0o700) + profile, _ = synthetic_shards(model) + config = b"{}" + (model / "config.json").write_bytes(config) + profile["config"] = {} + profile["files"]["config.json"] = len(config) + profile["hashes"]["config.json"] = hashlib.sha256(config).hexdigest() + profile["max_rows"] = 4 + data = root / "input.json" + data.write_text(json.dumps(dataset())) + selected = dict(request(), mode="infer", model_root=str(model), output_root=str(output), + dataset_path=str(data)) + os.write(writer, control(1)) + hashed = [] + original = WORKER.file_hash + + def checked(path, *args, **kwargs): + self.assertIs(kwargs.get("session"), session) + hashed.append(path.name) + return original(path, *args, **kwargs) + + with mock.patch.object(WORKER, "model_profile", return_value=profile), \ + mock.patch.object(WORKER, "file_hash", side_effect=checked): + prepared = WORKER.prepare_files(selected, session=session) + self.assertEqual(set(hashed), set(profile["files"])) + self.assertEqual(set(prepared[4]), set(profile["files"])) + damaged = model / profile["weight_shards"][-1] + damaged.write_bytes(b"x" * damaged.stat().st_size) + with self.assertRaisesRegex(WORKER.JobError, "MODEL_WEIGHTS_CHANGED_ON_DISK"): + WORKER.prepare_files(selected, session=session) + + +class NativeCpuTests(unittest.TestCase): + def test_effective_qwen3_rotary_dimension_matches_pinned_loader_without_weakening_required_fields(self): + config = dict(max_position_embeddings=262144, hidden_size=2560, num_hidden_layers=36, + intermediate_size=9728, num_attention_heads=32, num_key_value_heads=8, + head_dim=128, rope_theta=5000000, rms_norm_eps=1e-6) + values = dict(context_length=262144, embedding_length=2560, block_count=36, + feed_forward_length=9728) + values.update({"attention.head_count": 32, "attention.head_count_kv": 8, + "attention.key_length": 128, "attention.value_length": 128, + "rope.freq_base": 5000000.0, + "attention.layer_norm_rms_epsilon": struct.unpack("raw")) + session, observation = mock.Mock(), mock.Mock() + profile = WORKER.model_profile(native.PROFILE) + result = native.generate(model, prompt, tokenizer, profile, session, WORKER, observation) + batches = [entry.args[0] for entry in model.decode.call_args_list] + self.assertEqual(sum(batches[:36], []), prompt) + self.assertEqual(batches[36:], [[77], [78]]) + self.assertTrue(all(1 <= len(batch) <= 128 for batch in batches)) + self.assertEqual(result["generation"]["stop_reason"], "eos") + self.assertEqual(result["text"], "raw") + tokenizer.decode.assert_called_once_with([77, 78], skip_special_tokens=False) + observation.forward_complete.assert_called_once() + observation.complete.assert_called_once() + + def test_no_invented_eos_or_silent_prompt_truncation(self): + model = mock.Mock(); model.sample.return_value = 77 + tokenizer = SimpleNamespace(eos_token_id=151645, decode=mock.Mock(return_value="raw")) + profile = WORKER.model_profile(native.PROFILE) + result = native.generate(model, [1], tokenizer, profile, mock.Mock(), WORKER) + self.assertEqual(result["generation"]["stop_reason"], "token_limit") + self.assertEqual(result["generated_tokens"], 1024) + self.assertEqual(model.sample.call_count, 1024) + with self.assertRaisesRegex(WORKER.JobError, "NATIVE_PROMPT_BOUND"): + native.generate(model, [1] * 12289, tokenizer, profile, mock.Mock(), WORKER) + + def test_failed_decode_does_not_sample_or_decode_text(self): + model = mock.Mock(); model.decode.side_effect = WORKER.JobError("NATIVE_DECODE_FAILED") + tokenizer = mock.Mock() + with self.assertRaisesRegex(WORKER.JobError, "NATIVE_DECODE_FAILED"): + native.generate(model, [1], tokenizer, WORKER.model_profile(native.PROFILE), mock.Mock(), WORKER) + model.sample.assert_not_called(); tokenizer.decode.assert_not_called() + + def test_raw_output_wire_bound_stays_original(self): + model = mock.Mock(); model.sample.return_value = 151645 + tokenizer = SimpleNamespace(eos_token_id=151645, decode=mock.Mock(return_value="漢" * 2000)) + result = native.generate(model, [1], tokenizer, WORKER.model_profile(native.PROFILE), mock.Mock(), WORKER) + self.assertTrue(result["text_truncated"]) + self.assertLessEqual(len(json.dumps(result["text"], ensure_ascii=True).encode()), 4096) + + def test_c_abi_handle_closes_after_joined_python_error_and_only_once(self): + library = mock.Mock() + library.vp_llama_abi_v1.return_value = 1 + library.vp_llama_source_v1.return_value = native.SOURCE.encode() + library.vp_llama_open_stage_v1.return_value = 8 + def allocate(_path, _capacity, _threads, _callback, _opaque, out): + out._obj.value = 123 + return 0 + library.vp_llama_open_v1.side_effect = allocate + owner = mock.Mock() + owner.joined.side_effect = WORKER.JobError("JOB_CANCELLED") + with mock.patch.object(native.ctypes, "CDLL", return_value=library), \ + self.assertRaisesRegex(WORKER.JobError, "JOB_CANCELLED"): + native.NativeModel(Path("/native-backend"), 5600, 2, owner, WORKER.require) + library.vp_llama_close_v1.assert_called_once() + owner.joined.side_effect = None + library.vp_llama_close_v1.reset_mock() + with mock.patch.object(native.ctypes, "CDLL", return_value=library): + model = native.NativeModel(Path("/native-backend"), 5600, 2, owner, WORKER.require) + model.close(); model.close() + library.vp_llama_close_v1.assert_called_once() + + def test_abi_requires_closed_open_stage_even_when_existing_abi_version_matches(self): + codes = {1: "NATIVE_OPEN_INVALID_ARGUMENT", 2: "NATIVE_CPU_UNSUPPORTED", + 3: "NATIVE_BACKEND_INITIALIZATION_FAILED", 4: "NATIVE_MODEL_INITIALIZATION_FAILED", + 5: "NATIVE_VOCABULARY_MISMATCH", 6: "NATIVE_CONTEXT_INITIALIZATION_FAILED", + 7: "NATIVE_SAMPLER_INITIALIZATION_FAILED", 8: "NATIVE_MODEL_LOAD_FAILED"} + for stage, expected in [*codes.items(), (0, "NATIVE_OPEN_DIAGNOSTIC_INVALID"), + (9, "NATIVE_OPEN_DIAGNOSTIC_INVALID"), + (True, "NATIVE_OPEN_DIAGNOSTIC_INVALID")]: + with self.subTest(stage=stage): + library = mock.Mock() + library.vp_llama_abi_v1.return_value = 1 + library.vp_llama_source_v1.return_value = native.SOURCE.encode() + library.vp_llama_open_stage_v1.return_value = stage + library.vp_llama_open_v1.return_value = 3 + with mock.patch.object(native.ctypes, "CDLL", return_value=library), \ + self.assertRaisesRegex(WORKER.JobError, expected): + native.NativeModel(Path("/inert"), 16, 2, mock.Mock(), WORKER.require) + library.vp_llama_decode_v1.assert_not_called() + library.vp_llama_close_v1.assert_not_called() + # An older ABI1 library cannot silently use the new patched provenance. + library = SimpleNamespace(vp_llama_abi_v1=mock.Mock(return_value=1), + vp_llama_source_v1=mock.Mock(return_value=native.SOURCE.encode())) + with mock.patch.object(native.ctypes, "CDLL", return_value=library), \ + self.assertRaisesRegex(WORKER.JobError, "NATIVE_ABI_MISMATCH"): + native.NativeModel(Path("/inert"), 16, 2, mock.Mock(), WORKER.require) + + def test_success_handle_with_wrong_open_stage_is_rejected_and_closed(self): + library = mock.Mock() + library.vp_llama_abi_v1.return_value = 1 + library.vp_llama_source_v1.return_value = native.SOURCE.encode() + library.vp_llama_open_stage_v1.return_value = 4 + def allocate(_path, _capacity, _threads, _callback, _opaque, out): + out._obj.value = 123 + return 0 + library.vp_llama_open_v1.side_effect = allocate + with mock.patch.object(native.ctypes, "CDLL", return_value=library), \ + self.assertRaisesRegex(WORKER.JobError, "NATIVE_OPEN_DIAGNOSTIC_INVALID"): + native.NativeModel(Path("/inert"), 16, 2, mock.Mock(), WORKER.require) + library.vp_llama_close_v1.assert_called_once() + + def test_effective_loader_provenance_rejects_old_or_ambiguous_builds(self): + value = dict(version=2, source_commit=native.SOURCE, source_tree=native.SOURCE_TREE, + source_overlay=native.loader_provenance(), loader_compile_source_verified=True, + upstream_original_unchanged=True) + native.validate_build_provenance(value, WORKER.require) + for key, bad in (("version", 1), ("source_tree", "0" * 40), ("source_commit", "0" * 40), + ("loader_compile_source_verified", 1), ("upstream_original_unchanged", False)): + with self.subTest(key=key), self.assertRaisesRegex(WORKER.JobError, "NATIVE_BUILD_PATCH_BINDING"): + native.validate_build_provenance(dict(value, **{key: bad}), WORKER.require) + for key, bad in (("validation_execution_threads", True), ("complete_tensor_validation", 1), + ("owner_poll_between_tensors", 1), ("effective_sha256", native.LOADER_ORIGINAL_SHA), + ("original_sha256", "0" * 64), ("patch_sha256", "0" * 64), + ("compiled_source", "src/llama-model-loader.cpp")): + changed = copy.deepcopy(value) + changed["source_overlay"][key] = bad + with self.subTest(key=key), self.assertRaisesRegex(WORKER.JobError, "NATIVE_BUILD_PATCH_BINDING"): + native.validate_build_provenance(changed, WORKER.require) + + def test_compilation_must_use_exact_staged_loader_not_clean_upstream(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "build").mkdir() + loader = root / "llama-model-loader.cpp" + loader.write_bytes(b"inert-source") + commands = root / "build/compile_commands.json" + commands.write_text(json.dumps([{"file": str(loader)}])) + with mock.patch.object(native, "LOADER_EFFECTIVE_SHA", hashlib.sha256(b"inert-source").hexdigest()): + builder.verify_loader_compilation(root, loader) + for rows in ([{"file": "/unmodified/llama-model-loader.cpp"}], [], + [{"file": str(loader)}, {"file": str(loader)}]): + commands.write_text(json.dumps(rows)) + with self.assertRaises(ValueError): + builder.verify_loader_compilation(root, loader) + commands.write_text(json.dumps([{"file": str(loader)}])) + loader.write_bytes(b"changed-source") + with self.assertRaisesRegex(ValueError, "NATIVE_COMPILED_LOADER"): + builder.verify_loader_compilation(root, loader) + + def test_patch_identity_and_fresh_staging_never_overwrite_existing_source(self): + patch = builder.HERE / "native-cpu/bounded-tensor-validation.patch" + self.assertEqual(builder.digest(patch)["sha256"], native.LOADER_PATCH_SHA) + text = patch.read_text() + self.assertEqual(text.count("+ validation_result.emplace_back(std::async(std::launch::deferred,"), 1) + self.assertEqual(text.count("+ validation_result.emplace_back(std::async(std::launch::deferred,"), 1) + self.assertNotIn("- return std::make_pair", text) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + with mock.patch.object(builder, "patched_loader", return_value=b"inert-source"): + loader = builder.stage_loader(root / "original", root) + self.assertEqual(loader.read_bytes(), b"inert-source") + self.assertEqual(stat.S_IMODE(loader.stat().st_mode), 0o444) + self.assertEqual(stat.S_IMODE(loader.parent.stat().st_mode), 0o700) + with self.assertRaises(FileExistsError): + builder.stage_loader(root / "original", root) + self.assertEqual(loader.read_bytes(), b"inert-source") + + def test_native_execute_rejects_implicit_sampling_before_any_library_or_model_load(self): + bridge = mock.Mock() + bridge.generation_policy.return_value = None + with mock.patch.object(WORKER, "conversation_module", return_value=bridge), \ + mock.patch.object(native, "NativeModel", side_effect=AssertionError("must not load")), \ + self.assertRaisesRegex(WORKER.JobError, "NATIVE_GREEDY_POLICY_REQUIRED"): + native.execute(job(), mock.Mock(), mock.Mock(), mock.Mock(), {}, None, None, {}, {}, {}, WORKER, + initial_source_weights=None) + + def test_tensor_mapping_is_complete_for_exact_qwen_layers_not_arbitrary_names(self): + self.assertEqual(conversion.tensor_name("model.layers.35.self_attn.q_norm.weight"), "blk.35.attn_q_norm.weight") + self.assertEqual(conversion.tensor_name("model.embed_tokens.weight"), "token_embd.weight") + for name in ("model.layers.36.mlp.up_proj.weight", "model.layers.0.injected.weight", "lm_head.weight"): + with self.assertRaises(ValueError): conversion.tensor_name(name) + + def test_bf16_tensor_values_exact_and_norm_promotions_exact_including_signed_zero(self): + raw = struct.pack("<4H", 0x3f80, 0x8000, 0xbf00, 0x4000) + source = {"dtype": "BF16", "shape": [2, 2], "data_offsets": [0, 8]} + target = SimpleNamespace(name="blk.0.attn_q.weight", shape=[2, 2], n_elements=4, tensor_type=30, data=bytearray(raw)) + conversion.compare_tensor(io.BytesIO(raw), 0, source, target, lambda: None) + target.data[0] ^= 1 + with self.assertRaisesRegex(ValueError, "VALUE_CHANGED"): + conversion.compare_tensor(io.BytesIO(raw), 0, source, target, lambda: None) + source["shape"] = [4] + target.shape, target.name, target.tensor_type = [4], "output_norm.weight", 0 + target.data = bytearray(struct.pack("<4I", *(value << 16 for value in struct.unpack("<4H", raw)))) + conversion.compare_tensor(io.BytesIO(raw), 0, source, target, lambda: None) + # Positive zero may be numerically equal but must not replace negative zero. + target.data[7] = 0 + with self.assertRaisesRegex(ValueError, "VALUE_CHANGED"): + conversion.compare_tensor(io.BytesIO(raw), 0, source, target, lambda: None) + + def test_wrong_tensor_dtype_shape_and_f32_matrix_are_rejected(self): + raw = b"\0" * 8 + source = {"dtype": "BF16", "shape": [2, 2], "data_offsets": [0, 8]} + for dtype, shape in ((1, [2, 2]), (8, [2, 2]), (0, [2, 2]), (30, [4])): + target = SimpleNamespace(name="blk.0.attn_q.weight", shape=shape, n_elements=4, tensor_type=dtype, data=bytearray(raw)) + with self.assertRaises(ValueError): conversion.compare_tensor(io.BytesIO(raw), 0, source, target, lambda: None) + + def test_shared_library_dependency_policy_has_no_side_loading(self): + self.assertEqual(builder.dependencies("0 (NEEDED) Shared library: [libc.so.6]\n"), ["libc.so.6"]) + for value in ("0 (NEEDED) [libpeer.so]\n", "0 (NEEDED) [libc.so.6]\n0 (RUNPATH) [/peer]\n", ""): + with self.assertRaises(ValueError): builder.dependencies(value) + + def test_converted_bundle_files_are_owner_private_and_not_shared_0444(self): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + (root / "licenses").mkdir() + for path in (root / "backend.json", root / native.LIBRARY, root / "model.gguf", root / "licenses/MIT"): + path.write_bytes(b"inert") + path.chmod(0o444) + conversion.private_bundle_modes(root) + self.assertEqual(stat.S_IMODE(root.stat().st_mode), 0o700) + for path in root.rglob("*"): + self.assertEqual(stat.S_IMODE(path.stat().st_mode), 0o700 if path.is_dir() else 0o400) + + def test_provision_native_selection_preserves_default_and_original_budget(self): + provision = conversion.provision + args = SimpleNamespace(model_profile=native.PROFILE, native_cpu_source=None, native_cpu_build=None) + self.assertIsNone(provision.native_cpu_options(args)) + args.native_cpu_source = "/pinned/source" + with self.assertRaises(provision.ProvisionError): provision.native_cpu_options(args) + args.native_cpu_build = "/pinned/build" + self.assertEqual(provision.native_cpu_options(args)["kind"], native.KIND) + args.model_profile = WORKER.QWEN_MODEL_PROFILE + with self.assertRaises(provision.ProvisionError): provision.native_cpu_options(args) + args.model_profile = native.PROFILE + args.budget_bytes = 8 * 1024 ** 3 + with mock.patch.object(provision, "run_checked", side_effect=AssertionError("budget refusal must precede execution")), \ + self.assertRaisesRegex(provision.ProvisionError, "same provisioning budget"): + provision.provision_native_cpu(args, Path("/new"), "/runtime/python", {}, 1000, 2 * 1024 ** 3) + + def test_conversion_subwindow_caps_at_1800_without_rejecting_original_3600_deadline(self): + provision = conversion.provision + args = SimpleNamespace(model_profile=native.PROFILE, native_cpu_source="/pinned/source", + native_cpu_build="/pinned/build", budget_bytes=20 * 1024 ** 3) + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + def completed(command, environment, supplied_root, supplied_deadline): + self.assertEqual(supplied_deadline, 4000) + self.assertEqual(command[command.index("--timeout-seconds") + 1], "1800") + self.assertEqual(command[command.index("--budget-bytes") + 1], str(10 * 1024 ** 3)) + target = root / "native-backend" + target.mkdir(mode=0o700) + (target / "backend.json").write_bytes(b"{}") + (target / "backend.json").chmod(0o400) + with mock.patch.object(provision.time, "monotonic", return_value=1000), \ + mock.patch.object(provision, "run_checked", side_effect=completed) as invoked: + result = provision.provision_native_cpu(args, root, "/runtime/python", {}, 4000, 10 * 1024 ** 3) + self.assertEqual(result["backend_sha256"], hashlib.sha256(b"{}").hexdigest()) + invoked.assert_called_once() + + +if __name__ == "__main__": + unittest.main() diff --git a/workers/volparossa-ml/test_native_cpu_alignment.py b/workers/volparossa-ml/test_native_cpu_alignment.py new file mode 100644 index 000000000..3abd300ad --- /dev/null +++ b/workers/volparossa-ml/test_native_cpu_alignment.py @@ -0,0 +1,158 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Inert source/provenance checks. No compiler, model, downloads or native load.""" +import copy +import difflib +import hashlib +import json +from pathlib import Path +import stat +import tempfile +import unittest +from unittest import mock + +import build_llama_cpu as builder +import llama_cpu as native + + +def sha(raw): + return hashlib.sha256(raw).hexdigest() + + +def require(condition, code): + if not condition: + raise ValueError(code) + + +class AlignmentTests(unittest.TestCase): + def test_patch_contains_only_the_aligned_copy(self): + raw = (builder.HERE / "native-cpu/q1-aligned-load.patch").read_bytes() + self.assertEqual(sha(raw), native.CPU_PATCH_SHA) + lines = raw.decode().splitlines() + self.assertEqual([line for line in lines if line.startswith('-') and not line.startswith('---')], + ['- const uint32_t * GGML_RESTRICT qs32 = (const uint32_t *) x[ib].qs;']) + self.assertEqual([line for line in lines if line.startswith('+') and not line.startswith('+++')], + ['+ uint32_t qs32[4];', '+ memcpy(qs32, x[ib].qs, sizeof(qs32));']) + + def test_historical_absence_is_not_patch_evidence(self): + for version in (2, 3, 4): + value = dict(version=version) + native.validate_cpu_provenance(value, require) + self.assertEqual(value, dict(version=version)) + native.validate_cpu_provenance(dict(version=4, cpu_source_overlay=native.cpu_provenance(), + cpu_compile_source_verified=True), require) + + def test_provenance_is_an_atomic_exact_pair(self): + good = dict(version=4, cpu_source_overlay=native.cpu_provenance(), cpu_compile_source_verified=True) + invalid = [dict(version=4, cpu_source_overlay=native.cpu_provenance()), + dict(version=4, cpu_compile_source_verified=True)] + for version in (2, 3, 5, True, '4'): + invalid.append(dict(good, version=version)) + for verified in (False, 1, None, 'true'): + invalid.append(dict(good, cpu_compile_source_verified=verified)) + for key in native.cpu_provenance(): + bad = copy.deepcopy(good) + bad['cpu_source_overlay'][key] = 'unreviewed' + invalid.append(bad) + bad = copy.deepcopy(good) + del bad['cpu_source_overlay'][key] + invalid.append(bad) + bad = copy.deepcopy(good) + bad['cpu_source_overlay']['extra'] = True + invalid.append(bad) + for bad in invalid: + with self.subTest(bad=bad), self.assertRaisesRegex(ValueError, 'NATIVE_CPU_PATCH_BINDING'): + native.validate_cpu_provenance(bad, require) + + def test_exact_transformation_preserves_the_original_and_rejects_tampering(self): + before = b'prefix\n const uint32_t * GGML_RESTRICT qs32 = (const uint32_t *) x[ib].qs;\nsuffix\n' + after = b'prefix\n uint32_t qs32[4];\n memcpy(qs32, x[ib].qs, sizeof(qs32));\nsuffix\n' + patch = ''.join(difflib.unified_diff(before.decode().splitlines(True), after.decode().splitlines(True), + fromfile='a/' + native.CPU_ORIGINAL_PATH, tofile='b/' + native.CPU_ORIGINAL_PATH)).encode() + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + source = root / 'source' + original = source / native.CPU_ORIGINAL_PATH + original.parent.mkdir(parents=True) + original.write_bytes(before) + patch_path = root / 'wrapper/native-cpu/q1-aligned-load.patch' + patch_path.parent.mkdir(parents=True) + patch_path.write_bytes(patch) + with mock.patch.object(builder, 'HERE', root / 'wrapper'), \ + mock.patch.object(native, 'CPU_ORIGINAL_SHA', sha(before)), \ + mock.patch.object(native, 'CPU_EFFECTIVE_SHA', sha(after)), \ + mock.patch.object(native, 'CPU_PATCH_SHA', sha(patch)): + self.assertEqual(builder.patched_cpu(source), after) + self.assertEqual(original.read_bytes(), before) + original.write_bytes(before + b'changed') + with self.assertRaisesRegex(ValueError, 'NATIVE_CPU_ORIGINAL'): + builder.patched_cpu(source) + original.write_bytes(before) + patch_path.write_bytes(patch + b'changed') + with self.assertRaisesRegex(ValueError, 'NATIVE_CPU_PATCH'): + builder.patched_cpu(source) + patch_path.write_bytes(patch) + with mock.patch.object(native, 'CPU_EFFECTIVE_SHA', '0' * 64), \ + self.assertRaisesRegex(ValueError, 'NATIVE_CPU_EFFECTIVE'): + builder.patched_cpu(source) + + def test_staging_is_fresh_read_only_and_never_replaces_source(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + with mock.patch.object(builder, 'patched_cpu', return_value=b'inert reviewed source'): + staged = builder.stage_cpu(root / 'original', root) + self.assertEqual(staged.read_bytes(), b'inert reviewed source') + self.assertEqual(stat.S_IMODE(staged.stat().st_mode), 0o444) + self.assertEqual(stat.S_IMODE(staged.parent.stat().st_mode), 0o700) + with self.assertRaises(FileExistsError): + builder.stage_cpu(root / 'original', root) + self.assertEqual(staged.read_bytes(), b'inert reviewed source') + + def test_compilation_binds_only_the_staged_x86_unit_and_cpu_target(self): + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + source = root / 'original' + output = root / 'output' + staged = output / 'cpu-overlay/quants.c' + staged.parent.mkdir(parents=True) + staged.write_bytes(b'reviewed CPU source') + database = output / 'build/compile_commands.json' + database.parent.mkdir() + row = dict(directory=str(database.parent), file=str(staged), + command='cc -o CMakeFiles/ggml-cpu.dir/cpu-overlay/quants.c.o -c ' + str(staged)) + generic = dict(directory=str(database.parent), file=str(source / 'ggml/src/ggml-cpu/quants.c'), + command='cc -o CMakeFiles/ggml-cpu.dir/generic.o -c generic.c') + with mock.patch.object(native, 'CPU_EFFECTIVE_SHA', sha(staged.read_bytes())): + database.write_text(json.dumps([row, generic])) + builder.verify_cpu_compilation(output, source) + relative = dict(row, command='cc -o CMakeFiles/ggml-cpu.dir/x.o -c ../cpu-overlay/quants.c') + database.write_text(json.dumps([relative, generic])) + builder.verify_cpu_compilation(output, source) + bad_rows = [[], [generic], [row, row], + [row, dict(generic, file=str(source / native.CPU_ORIGINAL_PATH))], + [dict(row, command='cc -o CMakeFiles/other.dir/quants.c.o -c ignored')], + [dict(row, command='cc -c ignored')], + [dict(row, command='cc -o CMakeFiles/ggml-cpu.dir/x.o -c ' + str(source / native.CPU_ORIGINAL_PATH))], + [dict(row, command='cc -o CMakeFiles/ggml-cpu.dir/x.o -c different.c')], + [dict(row, command='cc -o CMakeFiles/ggml-cpu.dir/x.o')], + [dict(row, command='cc -o CMakeFiles/ggml-cpu.dir/x.o -c')], + [dict(row, command=row['command'] + ' -c ' + str(staged))]] + for rows in bad_rows: + database.write_text(json.dumps(rows)) + with self.subTest(rows=rows), self.assertRaisesRegex(ValueError, 'NATIVE_'): + builder.verify_cpu_compilation(output, source) + database.write_text(json.dumps([row])) + staged.write_bytes(b'changed') + with self.assertRaisesRegex(ValueError, 'NATIVE_COMPILED_CPU'): + builder.verify_cpu_compilation(output, source) + + def test_cmake_preserves_other_units_and_uses_original_private_headers(self): + text = (builder.HERE / 'native-cpu/CMakeLists.txt').read_text() + self.assertIn('if(source STREQUAL "${VP_LLAMA_SOURCE}/ggml/src/ggml-cpu/arch/x86/quants.c")', text) + self.assertIn('if(NOT cpu_replacements EQUAL 1)', text) + self.assertIn('list(APPEND effective_cpu_sources "${source}")', text) + self.assertIn('target_include_directories(ggml-cpu PRIVATE "${VP_LLAMA_SOURCE}/ggml/src/ggml-cpu/arch/x86")', text) + self.assertIn('q1-aligned-load.patch', builder.WRAPPER_FILES) + + +if __name__ == '__main__': + unittest.main() diff --git a/workers/volparossa-ml/test_native_output_constraint.py b/workers/volparossa-ml/test_native_output_constraint.py new file mode 100644 index 000000000..92c2658c7 --- /dev/null +++ b/workers/volparossa-ml/test_native_output_constraint.py @@ -0,0 +1,369 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Inert admission/provenance/ABI tests; no native build or model execution.""" +import copy +import hashlib +import json +from pathlib import Path +import tempfile +import sys +from types import SimpleNamespace +import unittest +from unittest import mock + +import llama_cpu as native +from test_conversation import CONVERSATION, conversation +from test_llama_cpu import job, manifest +from test_worker_protocol import WORKER, request +from test_qwen_conversation import NATIVE + + +def selected(count=1): + return dict(job(), output_constraint=native.OUTPUT_CONSTRAINT, + output_constraint_tools=[f"vp_{index}" for index in range(count)]) + + +def selected_data(): + return dict(conversation(), generation_policy="greedy_v1", output_constraint=native.OUTPUT_CONSTRAINT) + + +def build_manifest(version=2): + result = dict(version=version, abi_version=1, kind=native.KIND, + source_commit=native.SOURCE, source_tree=native.SOURCE_TREE, + source_overlay=native.loader_provenance(), loader_compile_source_verified=True, + upstream_original_unchanged=True, cpu="avx2_fma_f16c", provisionable=True, + sanitizers=False, quantization=False) + if version == 3: + result["output_constraints"] = [native.OUTPUT_CONSTRAINT] + return result + + +def bundle(root, version, build_version): + value = manifest() + value["version"] = version + if version == 2: + value["output_constraints"] = [native.OUTPUT_CONSTRAINT] + for field, raw in (("library", b"inert-not-library"), ("gguf", b"inert-not-model")): + (root / value[field]["path"]).write_bytes(raw) + value[field].update(bytes=len(raw), sha256=hashlib.sha256(raw).hexdigest()) + build = dict(build_manifest(build_version), library=value["library"]) + build_raw = json.dumps(build).encode() + (root / "build.json").write_bytes(build_raw) + value["build_manifest_sha256"] = hashlib.sha256(build_raw).hexdigest() + value["verification"]["chat_template_sha256"] = hashlib.sha256(b"unchanged").hexdigest() + raw = json.dumps(value).encode() + (root / "backend.json").write_bytes(raw) + return dict(job(), native_backend_root=str(root), native_backend_sha256=hashlib.sha256(raw).hexdigest()) + + +def library(extension=True): + def allocate(_path, _capacity, _threads, _callback, _opaque, output): + output._obj.value = 123 + return 0 + value = SimpleNamespace( + vp_llama_abi_v1=mock.Mock(return_value=1), + vp_llama_source_v1=mock.Mock(return_value=native.SOURCE.encode()), + vp_llama_open_stage_v1=mock.Mock(return_value=8), + vp_llama_open_v1=mock.Mock(side_effect=allocate), + vp_llama_decode_v1=mock.Mock(return_value=0), + vp_llama_sample_v1=mock.Mock(return_value=0), + vp_llama_close_v1=mock.Mock()) + if extension: + value.vp_llama_output_constraint_abi_v1 = mock.Mock(return_value=1) + value.vp_llama_set_output_constraint_v1 = mock.Mock(return_value=0) + return value + + +class ConstraintAdmissionTests(unittest.TestCase): + def test_legacy_requests_are_not_implicitly_constrained(self): + for value in (request(), job()): + result = WORKER.validate_request(value) + self.assertEqual(result, dict(value, steps=value.get("steps", 8), threads=2, max_seconds=600)) + self.assertNotIn("output_constraint", result) + self.assertNotIn("output_constraint_tools", result) + + def test_only_complete_core_alias_list_is_accepted(self): + for count in (1, 32): + value = selected(count) + self.assertEqual(WORKER.validate_request(value)["output_constraint_tools"], value["output_constraint_tools"]) + for changes in ({"output_constraint": None}, {"output_constraint": True}, + {"output_constraint": "user-grammar"}, {"output_constraint_tools": None}, + {"output_constraint_tools": []}, {"output_constraint_tools": ["vp_1"]}, + {"output_constraint_tools": ["vp_0", "vp_0"]}, + {"output_constraint_tools": ["read_private_file"]}, + {"output_constraint_tools": [True]}, + {"output_constraint_tools": [f"vp_{n}" for n in range(33)]}, + {"mode": "train"}, {"model_profile": WORKER.QWEN_MODEL_PROFILE}): + with self.subTest(changes=changes), self.assertRaises(WORKER.JobError): + WORKER.validate_request(dict(selected(), **changes)) + for field in ("output_constraint", "output_constraint_tools", "inference_backend"): + value = selected() + del value[field] + with self.subTest(field=field), self.assertRaises(WORKER.JobError): + WORKER.validate_request(value) + + def test_aliases_bind_actual_ordered_dataset_before_loading(self): + data = selected_data() + for selected_job, changed in ((selected(2), data), (selected(), dict(data, tools=[]))): + with mock.patch.object(WORKER, "conversation_module", return_value=CONVERSATION), \ + mock.patch.object(native, "verified_bundle", side_effect=AssertionError("must not access bundle")), \ + self.assertRaisesRegex((WORKER.JobError, ValueError), "OUTPUT_CONSTRAINT"): + native.execute(selected_job, mock.Mock(), None, None, {}, None, None, + changed, {}, {}, WORKER, initial_source_weights=None) + + def test_serialized_input_selector_is_closed_and_preserves_bytes(self): + data = selected_data() + raw = CONVERSATION.canonical(data) + self.assertIs(WORKER.validate_dataset(data, "private_conversation", native.PROFILE), data) + self.assertEqual(CONVERSATION.canonical(data), raw) + invalid = [dict(data, output_constraint=value) for value in + (None, True, 1, "", "private-grammar", [], {})] + invalid.extend((dict(data, generation_policy=None), dict(data, tools=[]), + dict(data, output_constraint_tools=["vp_0"]))) + no_policy = dict(data) + del no_policy["generation_policy"] + invalid.append(no_policy) + for changed in invalid: + with self.subTest(data=changed), self.assertRaises(WORKER.JobError): + WORKER.validate_dataset(changed, "private_conversation", native.PROFILE) + for profile in (CONVERSATION.QWEN, WORKER.DEFAULT_MODEL_PROFILE): + with self.assertRaises(WORKER.JobError): + WORKER.validate_dataset(data, "private_conversation", profile) + + def test_request_dataset_constraint_matches_in_both_directions_before_backend(self): + legacy = dict(conversation(), generation_policy="greedy_v1") + cases = ((selected(), legacy), (job(), selected_data()), + (dict(selected(), output_constraint="different"), selected_data())) + for selected_job, data in cases: + with self.subTest(request=selected_job), \ + mock.patch.object(native, "verified_bundle", side_effect=AssertionError("no native load")), \ + self.assertRaisesRegex(WORKER.JobError, "NATIVE_OUTPUT_CONSTRAINT_BINDING"): + native.execute(selected_job, mock.Mock(), None, None, {}, None, None, + data, {}, {}, WORKER, initial_source_weights=None) + with mock.patch.object(WORKER, "prepare_files", return_value=(None, None, data, {}, {})), \ + mock.patch.object(WORKER, "load_backend", side_effect=AssertionError("no backend import")), \ + self.assertRaisesRegex(WORKER.JobError, "NATIVE_OUTPUT_CONSTRAINT_BINDING"): + WORKER.execute_job(selected_job, mock.Mock()) + torch_job = WORKER.validate_request(dict(request(), mode="private_conversation", steps=1, + owner_control=True, model_profile=native.PROFILE)) + with mock.patch.object(WORKER, "prepare_files", return_value=(None, None, selected_data(), {}, {})), \ + mock.patch.object(WORKER, "load_backend", side_effect=AssertionError("no silent torch fallback")), \ + self.assertRaisesRegex(WORKER.JobError, "NATIVE_OUTPUT_CONSTRAINT_BINDING"): + WORKER.execute_job(torch_job, mock.Mock()) + + +class ConstraintProvenanceTests(unittest.TestCase): + def test_old_and_new_closed_manifest_shapes_do_not_mix(self): + legacy = manifest() + old_bytes = json.dumps(legacy).encode() + native.validate_manifest(legacy, WORKER.require) + self.assertEqual(json.dumps(legacy).encode(), old_bytes) + modern = dict(legacy, version=2, output_constraints=[native.OUTPUT_CONSTRAINT]) + native.validate_manifest(modern, WORKER.require) + for value in (dict(legacy, output_constraints=[]), dict(legacy, output_constraints=[native.OUTPUT_CONSTRAINT]), + dict(legacy, version=2), dict(modern, version=True), + dict(modern, output_constraints=[]), dict(modern, output_constraints=None), + dict(modern, output_constraints=[native.OUTPUT_CONSTRAINT, native.OUTPUT_CONSTRAINT]), + dict(modern, output_constraints=["private-grammar"]), dict(modern, grammar="secret")): + with self.subTest(value=value), self.assertRaises(WORKER.JobError): + native.validate_manifest(value, WORKER.require) + for version in (2, 3): + native.validate_build_provenance(build_manifest(version), WORKER.require) + for value in (dict(build_manifest(), output_constraints=[native.OUTPUT_CONSTRAINT]), + dict(build_manifest(3), output_constraints=[]), dict(build_manifest(3), version=4)): + with self.assertRaises(WORKER.JobError): + native.validate_build_provenance(value, WORKER.require) + + def test_verified_bundle_requires_matching_capabilities_and_request(self): + for version, build_version in ((1, 2), (2, 3), (1, 3), (2, 2)): + for constrained in (False, True): + with self.subTest(version=version, build=build_version, constrained=constrained), \ + tempfile.TemporaryDirectory() as directory: + value = bundle(Path(directory), version, build_version) + if constrained: + value.update(output_constraint=native.OUTPUT_CONSTRAINT, output_constraint_tools=["vp_0"]) + with mock.patch.object(native.ctypes, "CDLL", side_effect=AssertionError("no library execution")): + arguments = (value, WORKER, SimpleNamespace(chat_template="unchanged"), mock.Mock()) + if build_version != version + 1 or constrained and version == 1: + with self.assertRaises(WORKER.JobError): + native.verified_bundle(*arguments) + else: + _, _, identity = native.verified_bundle(*arguments) + self.assertNotIn("output_constraint", identity) + self.assertEqual(identity["manifest_sha256"], value["native_backend_sha256"]) + + +class ConstraintAbiTests(unittest.TestCase): + def open(self, value, **kwargs): + with mock.patch.object(native.ctypes, "CDLL", return_value=value): + return native.NativeModel(Path("/inert"), 512, 2, mock.Mock(), WORKER.require, **kwargs) + + def test_legacy_abi_without_extension_remains_usable(self): + value = library(False) + model = self.open(value) + model.close() + value.vp_llama_open_v1.assert_called_once() + value.vp_llama_close_v1.assert_called_once() + + def test_capability_cannot_be_claimed_by_old_or_wrong_library(self): + values = [library(False)] + for version in (0, 2, True): + value = library() + value.vp_llama_output_constraint_abi_v1.return_value = version + values.append(value) + for value in values: + with self.assertRaisesRegex(WORKER.JobError, "NATIVE_OUTPUT_CONSTRAINT_ABI_MISMATCH"): + self.open(value, output_constraints=[native.OUTPUT_CONSTRAINT]) + value.vp_llama_open_v1.assert_not_called() + + def test_declared_capability_does_not_enable_constraint_without_request(self): + value = library() + model = self.open(value, output_constraints=[native.OUTPUT_CONSTRAINT]) + model.close() + value.vp_llama_output_constraint_abi_v1.assert_called_once() + value.vp_llama_set_output_constraint_v1.assert_not_called() + + def test_constraint_is_configured_once_with_mode_and_actual_count_before_decode(self): + value = library() + events = [] + value.vp_llama_set_output_constraint_v1.side_effect = lambda *_args: events.append("configure") or 0 + value.vp_llama_decode_v1.side_effect = lambda *_args: events.append("decode") or 0 + model = self.open(value, output_constraints=[native.OUTPUT_CONSTRAINT], + output_constraint=native.OUTPUT_CONSTRAINT, output_constraint_tools=["vp_0", "vp_1"]) + model.decode([7]) + self.assertEqual(events, ["configure", "decode"]) + self.assertEqual(value.vp_llama_set_output_constraint_v1.call_args.args[1:], (1, 2)) + value.vp_llama_set_output_constraint_v1.assert_called_once() + model.close() + + def test_configure_failure_or_cancel_closes_handle_without_decode(self): + for cancelled in (False, True): + value, probe = library(), mock.Mock() + value.vp_llama_set_output_constraint_v1.return_value = 3 if not cancelled else 0 + if cancelled: + probe.joined.side_effect = [None, WORKER.JobError("JOB_CANCELLED")] + with mock.patch.object(native.ctypes, "CDLL", return_value=value), \ + self.assertRaisesRegex(WORKER.JobError, "JOB_CANCELLED" if cancelled else "CONFIGURATION_FAILED"): + native.NativeModel(Path("/inert"), 512, 2, probe, WORKER.require, + output_constraints=[native.OUTPUT_CONSTRAINT], + output_constraint=native.OUTPUT_CONSTRAINT, output_constraint_tools=["vp_0"]) + value.vp_llama_close_v1.assert_called_once() + value.vp_llama_decode_v1.assert_not_called() + + +class ConstraintResultTests(unittest.TestCase): + def test_output_tag_only_echoes_successfully_requested_constraint(self): + data = dict(conversation(), generation_policy="greedy_v1") + profile = WORKER.model_profile(native.PROFILE) + files = {name: {"bytes": profile["files"][name], "sha256": profile["hashes"][name]} + for name in profile["weights"]["files"]} + output = {"sample_index": 0, "text": "Ordinary answer.", "generated_tokens": 3, + "text_truncated": False, "generation": {"stop_reason": "eos"}} + bridge = SimpleNamespace(generation_policy=CONVERSATION.generation_policy, + output_constraint=CONVERSATION.output_constraint, + validate=CONVERSATION.validate, encode=lambda *_args: [1, 2], + decode=lambda *_args: {"type": "assistant", "text": output["text"]}, + capabilities=lambda *_args: {"inert": True}) + baseline = None + for declared, requested in ((False, False), (True, False), (True, True)): + with self.subTest(declared=declared, requested=requested), tempfile.TemporaryDirectory() as directory: + root = Path(directory) + raw = b"inert model file" + (root / "model.gguf").write_bytes(raw) + value = manifest() + value["gguf"].update(bytes=len(raw), sha256=hashlib.sha256(raw).hexdigest()) + if declared: + value.update(version=2, output_constraints=[native.OUTPUT_CONSTRAINT]) + model = mock.Mock() + selected_job = WORKER.validate_request(selected() if requested else job()) + with mock.patch.object(WORKER, "conversation_module", return_value=bridge), \ + mock.patch.object(native, "verified_bundle", return_value=(root, value, {})), \ + mock.patch.object(native, "NativeModel", return_value=model) as opened, \ + mock.patch.object(native, "generate", return_value=copy.deepcopy(output)), \ + mock.patch.object(WORKER, "finish_result", side_effect=lambda result, *_args: result): + result = native.execute(selected_job, mock.Mock(), None, None, {}, None, root, + selected_data() if requested else data, + {"sha256": "d" * 64}, files, WORKER, + initial_source_weights=profile["weights"]) + self.assertEqual(result["outputs"], [output]) + self.assertEqual(result["generation_policy"], "greedy_v1") + self.assertEqual(result["conversation"], {"type": "assistant", "text": output["text"]}) + self.assertEqual("output_constraint" in result, requested) + if requested: + self.assertEqual(result["output_constraint"], native.OUTPUT_CONSTRAINT) + self.assertEqual(opened.call_args.kwargs["output_constraint_tools"], ["vp_0"]) + elif declared: + self.assertEqual(opened.call_args.kwargs, {"output_constraints": [native.OUTPUT_CONSTRAINT]}) + else: + self.assertEqual(opened.call_args.kwargs, {}) + legacy_shape = dict(result) + legacy_shape.pop("output_constraint", None) + if baseline is None: + baseline = legacy_shape + self.assertEqual(legacy_shape, baseline, "no other legacy result field changes") + model.close.assert_called_once() + + def test_real_execute_chain_accepts_serialized_selection_and_unchanged_template(self): + data, profile = selected_data(), WORKER.model_profile(native.PROFILE) + raw = CONVERSATION.canonical(data).encode() + files = {name: {"bytes": profile["files"][name], "sha256": profile["hashes"][name]} + for name in profile["weights"]["files"]} + identity = {"sha256": hashlib.sha256(raw).hexdigest(), "bytes": len(raw), "visibility": "private_local"} + tokenizer = mock.Mock(pad_token_id=151643, eos_token_id=151645) + tokenizer.apply_chat_template.return_value = [1, 2] + transformers = mock.Mock() + transformers.AutoTokenizer.from_pretrained.return_value = tokenizer + output = {"sample_index": 0, "text": '{"name":"vp_0","arguments":{"name":"demo.rs"}}', + "generated_tokens": 3, "text_truncated": False, "generation": {"stop_reason": "eos"}} + with tempfile.TemporaryDirectory() as directory: + root, model = Path(directory), mock.Mock() + model_raw = b"inert model file" + (root / "model.gguf").write_bytes(model_raw) + value = dict(manifest(), version=2, output_constraints=[native.OUTPUT_CONSTRAINT]) + value["gguf"].update(bytes=len(model_raw), sha256=hashlib.sha256(model_raw).hexdigest()) + def prepared(_request, *, session, initial_source, source_cache): + self.assertIsInstance(source_cache, WORKER.NativeSourceCache) + initial_source["weights"] = profile["weights"] + parsed = WORKER.validate_dataset(WORKER.parse_json(raw), "private_conversation", native.PROFILE) + return root, root, parsed, identity, files + with mock.patch.dict(sys.modules, {"volparossa_llama_cpu": native, + "volparossa_qwen_conversation": NATIVE}), \ + mock.patch.object(WORKER, "prepare_files", side_effect=prepared), \ + mock.patch.object(WORKER, "configure_offline"), \ + mock.patch.object(WORKER, "load_backend", return_value=(None, transformers, None, {})), \ + mock.patch.object(native, "verified_bundle", return_value=(root, value, {})), \ + mock.patch.object(native, "NativeModel", return_value=model) as opened, \ + mock.patch.object(native, "generate", return_value=output), \ + mock.patch.object(WORKER, "finish_result", side_effect=lambda result, *_args: result): + result = WORKER.execute_job(WORKER.validate_request(selected()), mock.Mock()) + self.assertEqual(result["output_constraint"], native.OUTPUT_CONSTRAINT) + self.assertEqual(result["conversation"]["type"], "function_call") + self.assertEqual(result["conversation"]["arguments"], {"name": "demo.rs"}) + self.assertEqual(result["dataset"], identity) + self.assertEqual(opened.call_args.kwargs["output_constraint_tools"], ["vp_0"]) + template = tokenizer.apply_chat_template.call_args + legacy = dict(data) + del legacy["output_constraint"] + self.assertEqual(template.args[0], NATIVE.messages(legacy, native.PROFILE)) + self.assertEqual(template.kwargs["tools"], NATIVE.native_tools(legacy)) + model.close.assert_called_once() + + def test_selected_output_depth_counts_containers_not_envelope_or_strings(self): + for depth in (1, 16, 17): + args = {"name": "[[[{{{ literal text }}}]]]"} + for index in range(depth - 1): + args = {"child": args} if index % 2 == 0 else [args] + if type(args) is not dict: + # Preserve the required object root without increasing depth. + args = {"child": args[0]} + raw = '' + json.dumps({"name": "vp_0", "arguments": args}) + '' + output = {"text": raw, "text_truncated": False, "generation": {"stop_reason": "eos"}} + for constrained in (False, True): + data = selected_data() if constrained else dict(conversation(), generation_policy="greedy_v1") + result = CONVERSATION.decode(data, output, native.PROFILE, "a" * 32) + self.assertEqual(result["type"], "incomplete" if constrained and depth > 16 else "function_call") + if result["type"] == "incomplete": + self.assertEqual(result["reason"], "invalid_output") + + +if __name__ == "__main__": + unittest.main() diff --git a/workers/volparossa-ml/test_native_session.py b/workers/volparossa-ml/test_native_session.py new file mode 100644 index 000000000..3494d2942 --- /dev/null +++ b/workers/volparossa-ml/test_native_session.py @@ -0,0 +1,393 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Inert session protocol and exact-prefix tests; no model or native library.""" +import copy +import json +import os +import queue +import threading +import time +from pathlib import Path +from types import SimpleNamespace +import unittest +from unittest import mock + +import llama_cpu as native +import native_session as warm +from test_llama_cpu import job, manifest +from test_native_output_constraint import library +from test_worker_protocol import WORKER + + +def selected(turn=1): + return WORKER.validate_request(dict(job(), model_root="/model", dataset_path=f"/session/turn-{turn}/input.json", + output_root=f"/session/turn-{turn}/output", native_session={"version": 1, "turn": turn}, + id=f"{turn:032x}")) + + +def frame(value): + return json.dumps(value, separators=(",", ":")).encode() + b"\n" + + +class ContractTests(unittest.TestCase): + def test_selection_is_closed_native_only_and_fixed_paths(self): + self.assertEqual(selected()["native_session"], {"version": 1, "turn": 1}) + for value in (None, True, {}, {"version": True, "turn": 1}, {"version": 1, "turn": True}, + {"version": 1, "turn": 0}, {"version": 1, "turn": 129}, + {"version": 1, "turn": 1, "token": "private"}): + with self.subTest(value=value), self.assertRaises(WORKER.JobError): + WORKER.validate_request(dict(selected(), native_session=value)) + for key, value in (("model_root", "/elsewhere"), ("dataset_path", "/session/turn-2/input.json"), + ("output_root", "/session/turn-1/../output"), ("owner_control", False), + ("inference_backend", "other")): + with self.subTest(key=key), self.assertRaises(WORKER.JobError): + WORKER.validate_request(dict(selected(), **{key: value})) + + def test_legacy_absence_and_manifests_keep_their_own_shapes(self): + self.assertNotIn("native_session", WORKER.validate_request(job())) + for version in (1, 2, 3): + value = manifest() + value["version"] = version + if version >= 2: + value["output_constraints"] = [native.OUTPUT_CONSTRAINT] + if version == 3: + value["native_sessions"] = [native.NATIVE_SESSION] + self.assertEqual(native.validate_manifest(value, WORKER.require), value) + bad = copy.deepcopy(value) + if version == 3: + del bad["native_sessions"] + else: + bad["native_sessions"] = [native.NATIVE_SESSION] + with self.assertRaises(WORKER.JobError): + native.validate_manifest(bad, WORKER.require) + for value in (None, [], ["other"], [native.NATIVE_SESSION, native.NATIVE_SESSION]): + with self.subTest(value=value), self.assertRaises(WORKER.JobError): + native.validate_manifest(dict(manifest(), version=3, output_constraints=[native.OUTPUT_CONSTRAINT], + native_sessions=value), WORKER.require) + + def test_cpu_soft_delta_never_raises_fixed_hard_task_cap(self): + with mock.patch.object(warm.resource, "getrusage", return_value=SimpleNamespace(ru_utime=17.2, ru_stime=2.1)), \ + mock.patch.object(warm.resource, "getrlimit", return_value=(1200, 4800)), \ + mock.patch.object(warm.resource, "setrlimit") as set_limit: + warm.turn_cpu_limit(selected(), WORKER.require) + set_limit.assert_called_once_with(warm.resource.RLIMIT_CPU, (1220, 4800)) + for consumed, hard in ((float("nan"), 4800), (-1, 4800), (4800, 4800), (0, 9999)): + with self.subTest(consumed=consumed, hard=hard), \ + mock.patch.object(warm.resource, "getrusage", return_value=SimpleNamespace(ru_utime=consumed, ru_stime=0)), \ + mock.patch.object(warm.resource, "getrlimit", return_value=(1200, hard)), \ + mock.patch.object(warm.resource, "setrlimit") as set_limit, self.assertRaises(WORKER.JobError): + warm.turn_cpu_limit(selected(), WORKER.require) + set_limit.assert_not_called() + + def test_backend_configuration_and_tokenizer_are_once_per_owned_runtime(self): + backend = (mock.Mock(), mock.Mock(), mock.Mock(), {}) + tokenizer = object() + backend[1].AutoTokenizer.from_pretrained.return_value = tokenizer + with mock.patch.dict(warm.sys.modules, {"volparossa_llama_cpu": native}), \ + mock.patch.object(WORKER, "load_backend", return_value=backend) as load: + runtime = warm.Runtime(WORKER) + for _ in range(2): + self.assertIs(runtime.backend(2, mock.Mock()), backend) + self.assertIs(runtime.tokenizer(backend[1], Path("/model")), tokenizer) + load.assert_called_once() + backend[1].AutoTokenizer.from_pretrained.assert_called_once() + with self.assertRaises(WORKER.JobError): + runtime.backend(1, mock.Mock()) + runtime.close() + self.assertIsNone(runtime.loaded_tokenizer) + + +class PrefixTests(unittest.TestCase): + def setUp(self): + self.manifest = dict(manifest(), version=3, output_constraints=[native.OUTPUT_CONSTRAINT], + native_sessions=[native.NATIVE_SESSION]) + self.constructor = mock.patch.object(native, "NativeModel") + self.model_type = self.constructor.start() + self.addCleanup(self.constructor.stop) + + def begin(self, state, prompt, **changes): + request = dict(selected(), **changes) + return state.begin(Path("/native-backend"), self.manifest, {"library": "fixed"}, request, + prompt, mock.Mock(), WORKER) + + def test_only_exact_decoded_prefix_survives_and_last_prompt_token_is_recomputed(self): + state = native.RetainedModel() + self.assertEqual(self.begin(state, [1, 2, 3])[1], 0) + state.tokens = [1, 2, 3, 4] + self.assertEqual(self.begin(state, [1, 2, 3])[1], 2) + self.assertEqual(state.tokens, []) + state.tokens = [1, 2, 3] + self.assertEqual(self.begin(state, [1, 9, 3])[1], 1) + state.tokens = [1, 2, 3] + self.assertEqual(self.begin(state, [9, 2, 3])[1], 0) + self.assertEqual(self.model_type.call_count, 1) + self.assertEqual(self.model_type.call_args.args[1], 1280) + self.assertTrue(self.model_type.call_args.kwargs["context_capacity"]) + + def test_initial_capacity_covers_complete_prompt_and_unchanged_output_bound(self): + for length, capacity in ((1, 1280), (256, 1280), (257, 1536), (4572, 5632), (12288, 13312)): + with self.subTest(length=length): + state = native.RetainedModel() + self.assertEqual(self.begin(state, [1] * length)[1], 0) + self.assertEqual(state.capacity, capacity) + self.assertEqual(self.model_type.call_args.args[1], capacity) + self.assertGreaterEqual(capacity, length + 1024) + state.close() + + def test_invalid_full_prompt_is_rejected_before_model_allocation(self): + for prompt in ([], [1] * 12289, [True], [151936], [-1], (1, 2)): + with self.subTest(length=len(prompt)), self.assertRaisesRegex(WORKER.JobError, "NATIVE_PROMPT_BOUND"): + self.begin(native.RetainedModel(), prompt) + self.model_type.assert_not_called() + + def test_fit_keeps_prefix_and_growth_keeps_model_but_recomputes_entire_prompt(self): + state = native.RetainedModel() + model, _ = self.begin(state, [1] * 256) + state.tokens = [1] * 256 + self.assertEqual(self.begin(state, [1] * 256)[1], 255) + model.reserve_context.assert_not_called() + state.tokens = [1] * 256 + def grow(capacity, _probe): + self.assertEqual(capacity, 1536) + self.assertEqual(state.tokens, []) + model.reserve_context.side_effect = grow + prompt = [1] * 257 + same, prefix = self.begin(state, prompt) + self.assertIs(same, model) + self.assertEqual(prefix, 0) + self.assertEqual(self.model_type.call_count, 1) + model.close.assert_not_called() + model.sample.return_value = 151645 + native.generate(model, prompt, SimpleNamespace(eos_token_id=151645, decode=lambda *a, **k: ""), + WORKER.model_profile(native.PROFILE), mock.Mock(), WORKER, retained_tokens=prefix) + self.assertEqual([token for call in model.decode.call_args_list for token in call.args[0]], prompt) + self.assertEqual([len(call.args[0]) for call in model.decode.call_args_list], [128, 128, 1]) + state.tokens = prompt + self.assertEqual(self.begin(state, [1, 1])[1], 1) + self.assertEqual(state.capacity, 1536) # No shrink/reallocation on a shorter turn. + model.reserve_context.assert_called_once() + + def test_failed_or_cancelled_growth_clears_tokens_closes_and_cannot_retry(self): + for code in ("NATIVE_CONTEXT_INITIALIZATION_FAILED", "JOB_CANCELLED"): + with self.subTest(code=code): + self.model_type.reset_mock(return_value=True) + state = native.RetainedModel() + model, _ = self.begin(state, [1] * 256) + state.tokens = [1] * 256 + model.reserve_context.side_effect = WORKER.JobError(code) + with self.assertRaisesRegex(WORKER.JobError, code): + self.begin(state, [1] * 257) + self.assertEqual(state.tokens, []) + self.assertIsNone(state.model) + self.assertTrue(state.failed) + model.close.assert_called_once() + self.assertEqual(model.begin_turn.call_count, 1) + with self.assertRaisesRegex(WORKER.JobError, "NATIVE_SESSION_PREFIX_RESET_FAILED"): + self.begin(state, [1] * 257) + self.assertEqual(self.model_type.call_count, 1) + + def test_grown_context_begin_failure_also_closes_without_retry(self): + state = native.RetainedModel() + model, _ = self.begin(state, [1] * 256) + state.tokens = [1] * 256 + model.begin_turn.side_effect = WORKER.JobError("NATIVE_SESSION_PREFIX_RESET_FAILED") + with self.assertRaisesRegex(WORKER.JobError, "NATIVE_SESSION_PREFIX_RESET_FAILED"): + self.begin(state, [1] * 257) + model.reserve_context.assert_called_once() + model.close.assert_called_once() + self.assertEqual(state.tokens, []) + self.assertTrue(state.failed) + + def test_no_cross_instance_identity_or_failed_handle_replacement(self): + first, second = native.RetainedModel(), native.RetainedModel() + self.begin(first, [1, 2]) + first.tokens = [1, 2] + self.assertEqual(self.begin(second, [1, 2])[1], 0) + self.assertEqual(self.model_type.call_count, 2) + with self.assertRaises(WORKER.JobError): + self.begin(first, [1, 2], threads=1) + first.model.begin_turn.side_effect = WORKER.JobError("NATIVE_SESSION_PREFIX_RESET_FAILED") + with self.assertRaises(WORKER.JobError): + self.begin(first, [1, 2]) + self.assertEqual(self.model_type.call_count, 2) + first.close() + self.assertEqual(first.tokens, []) + self.assertIsNone(first.binding) + + def test_generate_reuses_no_uncomputed_sample_and_keeps_full_prompt_bound(self): + model = mock.Mock() + model.sample.side_effect = [4, 151645] + tokenizer = SimpleNamespace(eos_token_id=151645, decode=lambda *a, **k: "ok") + tokens = [] + result = native.generate(model, [1, 2, 3], tokenizer, WORKER.model_profile(native.PROFILE), + mock.Mock(), WORKER, retained_tokens=2, decoded_tokens=tokens) + self.assertEqual(tokens, [1, 2, 3, 4]) + self.assertEqual(model.decode.call_args_list, [mock.call([3]), mock.call([4])]) + self.assertEqual(result["generated_tokens"], 2) + for retain in (-1, 3, True): + with self.subTest(retain=retain), self.assertRaises(WORKER.JobError): + native.generate(model, [1, 2, 3], tokenizer, WORKER.model_profile(native.PROFILE), + mock.Mock(), WORKER, retained_tokens=retain) + + +class AbiTests(unittest.TestCase): + def open(self, value, **kwargs): + with mock.patch.object(native.ctypes, "CDLL", return_value=value): + return native.NativeModel(Path("/inert"), kwargs.pop("capacity", 12288 + 1024), + 2, mock.Mock(), WORKER.require, **kwargs) + + def capacity_library(self, version=1): + value = library() + value.vp_llama_native_session_abi_v1 = mock.Mock(return_value=1) + value.vp_llama_begin_turn_v1 = mock.Mock(return_value=0) + if version is not None: + value.vp_llama_context_capacity_abi_v1 = mock.Mock(return_value=version) + value.vp_llama_reserve_context_v1 = mock.Mock(return_value=0) + return value + + def test_capacity_extension_required_only_for_explicit_internal_selection(self): + for version in (None, 0, 2, True): + value = self.capacity_library(version) + with self.subTest(version=version), self.assertRaisesRegex(WORKER.JobError, "NATIVE_SESSION_ABI_MISMATCH"): + self.open(value, native_session=True, context_capacity=True) + value.vp_llama_open_v1.assert_not_called() + self.open(value, native_session=True).close() # Old callers retain the original v1 ABI. + value = library() + self.open(value).close() # Cold/legacy needs neither session nor capacity symbols. + value.vp_llama_open_v1.reset_mock() + with self.assertRaisesRegex(WORKER.JobError, "NATIVE_SESSION_ABI_MISMATCH"): + self.open(value, context_capacity=True) + value.vp_llama_open_v1.assert_not_called() + + def test_capacity_handover_uses_new_callback_and_no_model_reopen(self): + value = self.capacity_library() + model = self.open(value, native_session=True, context_capacity=True, capacity=1280) + previous, current = model.probe, mock.Mock() + def grow(handle, capacity, callback, opaque): + self.assertIs(model.probe, previous) # Old owner object stays alive until native returns. + self.assertIs(handle, model.handle) + self.assertEqual(capacity, 1536) + self.assertIs(callback, current.callback) + self.assertIsNone(opaque) + return 0 + value.vp_llama_reserve_context_v1.side_effect = grow + model.reserve_context(1536, current) + self.assertIs(model.probe, current) + self.assertEqual(model.capacity, 1536) + current.joined.assert_called_once() + value.vp_llama_open_v1.assert_called_once() + value.vp_llama_close_v1.assert_not_called() + for capacity in (True, 1536, 1280, 13313): + with self.subTest(capacity=capacity), self.assertRaisesRegex(WORKER.JobError, "NATIVE_SESSION_PREFIX_BOUND"): + model.reserve_context(capacity, current) + value.vp_llama_reserve_context_v1.assert_called_once() + model.close() + value.vp_llama_close_v1.assert_called_once() + + def test_capacity_failure_or_pending_cancel_never_commits_capacity(self): + for status, cancelled in ((3, False), (2, False), (0, True)): + value = self.capacity_library() + value.vp_llama_reserve_context_v1.return_value = status + model = self.open(value, native_session=True, context_capacity=True, capacity=1280) + current = mock.Mock() + if cancelled: + current.joined.side_effect = WORKER.JobError("JOB_CANCELLED") + with self.subTest(status=status, cancelled=cancelled), self.assertRaises(WORKER.JobError): + model.reserve_context(1536, current) + self.assertIs(model.probe, current) + self.assertEqual(model.capacity, 1280) + model.close() + value.vp_llama_close_v1.assert_called_once() + + def test_new_manifest_never_loads_old_or_mismatched_session_abi(self): + for version in (None, 0, 2, True): + value = library() + if version is not None: + value.vp_llama_native_session_abi_v1 = mock.Mock(return_value=version) + value.vp_llama_begin_turn_v1 = mock.Mock(return_value=0) + with self.subTest(version=version), self.assertRaisesRegex(WORKER.JobError, "NATIVE_SESSION_ABI_MISMATCH"): + self.open(value, native_session=True) + value.vp_llama_open_v1.assert_not_called() + + def test_selected_turn_resets_exact_prefix_and_does_not_call_legacy_setter(self): + value = library() + value.vp_llama_native_session_abi_v1 = mock.Mock(return_value=1) + value.vp_llama_begin_turn_v1 = mock.Mock(return_value=0) + model = self.open(value, native_session=True, output_constraints=[native.OUTPUT_CONSTRAINT]) + probe = mock.Mock() + model.begin_turn(12, probe, native.OUTPUT_CONSTRAINT, ["vp_0", "vp_1"]) + self.assertEqual(value.vp_llama_begin_turn_v1.call_args.args[1:4], (12, 1, 2)) + self.assertIs(value.vp_llama_begin_turn_v1.call_args.args[4], probe.callback) + self.assertIs(model.probe, probe) + value.vp_llama_set_output_constraint_v1.assert_not_called() + model.close() + value.vp_llama_close_v1.assert_called_once() + + +class PipeTests(unittest.TestCase): + def test_two_real_pipe_turns_drain_old_controls_before_next_request(self): + read, write = os.pipe() + output, errors = queue.Queue(), [] + runtime = mock.Mock() + def execute(request, session, *, native_session): + self.assertIs(native_session, runtime) + session.check() + return {"version": 1, "id": request["id"], "kind": "result", "status": "ok"} + def consume(): + try: + errors.append(warm.run(selected(), WORKER.InputFrames(read), WORKER)) + except BaseException as error: + errors.append(error) + with mock.patch.object(warm, "Runtime", return_value=runtime), \ + mock.patch.object(warm, "turn_cpu_limit"), \ + mock.patch.object(WORKER, "execute_job", side_effect=execute), \ + mock.patch.object(WORKER, "emit", side_effect=output.put): + thread = threading.Thread(target=consume, daemon=True) + thread.start() + try: + for turn in (1, 2): + request = selected(turn) + if turn == 2: + os.write(write, frame(request)) + os.write(write, frame({"version": 1, "id": request["id"], "sequence": 1, "action": "resume"})) + self.assertEqual(output.get(timeout=2)["phase"], "resumed") + self.assertEqual(output.get(timeout=2)["kind"], "result") + # A real old control in flight at result time must not become + # an invalid next request or authorize more model operations. + os.write(write, frame({"version": 1, "id": request["id"], "sequence": 2, "action": "pause"}) + + frame({"version": 1, "id": request["id"], "kind": "native_turn_finish"})) + self.assertEqual(output.get(timeout=2)["phase"], "paused") + self.assertEqual(output.get(timeout=2), {"version": 1, "id": request["id"], "kind": "native_turn_idle"}) + os.close(write) + write = None + thread.join(2) + self.assertFalse(thread.is_alive()) + self.assertEqual(errors, [0]) + runtime.close.assert_called_once() + finally: + if write is not None: + os.close(write) + thread.join(2) + os.close(read) + + def test_deadline_cancel_and_partial_eof_never_become_idle_success(self): + read, write = os.pipe() + try: + frames = WORKER.InputFrames(read) + with self.assertRaisesRegex(WORKER.JobError, "DEADLINE"): + warm.read_frame(frames, time.monotonic() - 1, WORKER, eof=True) + with mock.patch.object(WORKER, "is_stopping", return_value=True), \ + self.assertRaisesRegex(WORKER.JobError, "CANCELLED"): + warm.read_frame(frames, time.monotonic() + 1, WORKER, eof=True) + os.write(write, b'{"version":') + os.close(write) + write = None + with self.assertRaisesRegex(WORKER.JobError, "OWNER_CONTROL_CLOSED"): + warm.read_frame(frames, time.monotonic() + 1, WORKER, eof=True) + finally: + if write is not None: + os.close(write) + os.close(read) + + +if __name__ == "__main__": + unittest.main() diff --git a/workers/volparossa-ml/test_native_source_lifecycle.py b/workers/volparossa-ml/test_native_source_lifecycle.py new file mode 100644 index 000000000..e803707c3 --- /dev/null +++ b/workers/volparossa-ml/test_native_source_lifecycle.py @@ -0,0 +1,310 @@ +# SPDX-License-Identifier: GPL-3.0-only +"""Real tiny hashes and descriptor lifetimes, never models or cache reclamation.""" +import contextlib +import errno +import hashlib +import json +import os +from pathlib import Path +import tempfile +from types import SimpleNamespace +import unittest +from unittest import mock + +import llama_cpu as native +from test_llama_cpu import WORKER, controlled_pipe, control, manifest, source_fixture + + +def fixture(root): + selected, profile = source_fixture(root) + for path in Path(selected['model_root']).iterdir(): + path.chmod(0o600) + return selected, profile + + +def bundle(root, selected, template): + root.mkdir(mode=0o700) + value = manifest() + for field, raw in (('library', b'inert library bytes'), ('gguf', b'inert GGUF bytes')): + (root / value[field]['path']).write_bytes(raw) + value[field].update(bytes=len(raw), sha256=hashlib.sha256(raw).hexdigest()) + provenance = dict(version=2, abi_version=1, kind=native.KIND, source_commit=native.SOURCE, + source_tree=native.SOURCE_TREE, source_overlay=native.loader_provenance(), + loader_compile_source_verified=True, upstream_original_unchanged=True, + cpu='avx2_fma_f16c', library=value['library'], provisionable=True, + sanitizers=False, quantization=False) + raw = json.dumps(provenance).encode() + (root / 'build.json').write_bytes(raw) + value['build_manifest_sha256'] = hashlib.sha256(raw).hexdigest() + value['verification']['chat_template_sha256'] = hashlib.sha256(template.encode()).hexdigest() + raw = json.dumps(value).encode() + (root / 'backend.json').write_bytes(raw) + selected.update(native_backend_root=str(root), native_backend_sha256=hashlib.sha256(raw).hexdigest()) + + +@contextlib.contextmanager +def prepared(): + with tempfile.TemporaryDirectory() as directory: + selected, profile = fixture(Path(directory)) + with mock.patch.object(WORKER, 'model_profile', return_value=profile): + with WORKER.NativeSourceCache(selected) as cache: + result = WORKER.prepare_files(selected, initial_source={}, source_cache=cache) + yield cache, selected, profile, result + + +class NativeSourceLifecycleTests(unittest.TestCase): + def assert_closed(self, descriptors): + for descriptor in descriptors: + with self.assertRaises(OSError) as error: + os.fstat(descriptor) + self.assertEqual(error.exception.errno, errno.EBADF) + + def test_real_execute_hash_chain_advises_only_verified_shards_before_backend_and_gguf(self): + self.execute_chain() + + def test_real_execute_keeps_result_when_advice_is_unavailable(self): + for error in (OSError(errno.EPERM, 'private error'), AttributeError('unsupported')): + with self.subTest(error=type(error).__name__): + self.execute_chain(error) + + def execute_chain(self, advice_error=None): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + selected, profile = fixture(root) + template = 'inert template' + bundle(root / 'native', selected, template) + events, descriptors, flags = [], [], [] + tokenizer = SimpleNamespace(pad_token_id=151643, eos_token_id=151645, chat_template=template) + transformers = SimpleNamespace(AutoTokenizer=SimpleNamespace(from_pretrained=lambda *a, **k: tokenizer)) + original_hash, original_open = WORKER.file_hash, os.open + + def hashed(path, *args, **kwargs): + value = original_hash(path, *args, **kwargs) + events.append(('hashed', path.name)) + return value + + def opened(path, bits, *args, **kwargs): + flags.append((Path(path).name, bits)) + return original_open(path, bits, *args, **kwargs) + + def advise(descriptor, offset, size, hint): + name = next(name for name in profile['weight_shards'] + if (root / 'source' / name).stat().st_ino == os.fstat(descriptor).st_ino) + events.append(('advised', name)) + descriptors.append(descriptor) + self.assertEqual((offset, size, hint), (0, profile['files'][name], os.POSIX_FADV_DONTNEED)) + self.assertFalse(os.get_inheritable(descriptor)) + self.assertEqual(os.lseek(descriptor, 0, os.SEEK_CUR), size) + if advice_error is not None: + raise advice_error + + def load_backend(*args): + events.append(('backend', 'begin')) + self.assert_closed(descriptors) + return mock.Mock(), transformers, mock.Mock(), {} + + def execute(request, session, token, *_args, **kwargs): + self.assertEqual(kwargs['initial_source_weights'], profile['weights']) + _, value, _ = native.verified_bundle(request, WORKER, token, session) + self.assertEqual(value['gguf']['bytes'], len(b'inert GGUF bytes')) + return 'inert bundle verified; no library loaded' + + session = mock.Mock() + with mock.patch.object(WORKER, 'model_profile', return_value=profile), \ + mock.patch.object(WORKER, 'file_hash', side_effect=hashed), \ + mock.patch.object(WORKER.os, 'open', side_effect=opened), \ + mock.patch.object(WORKER.os, 'posix_fadvise', side_effect=advise), \ + mock.patch.object(WORKER, 'configure_offline'), \ + mock.patch.object(WORKER, 'load_backend', side_effect=load_backend), \ + mock.patch.dict(WORKER.sys.modules, {'volparossa_llama_cpu': SimpleNamespace(execute=execute)}), \ + mock.patch.object(native.ctypes, 'CDLL', side_effect=AssertionError('no native loading')): + self.assertEqual(WORKER.execute_job(selected, session), 'inert bundle verified; no library loaded') + source_events = [('hashed', name) for name in profile['files']] + self.assertCountEqual(events[:len(source_events)], source_events) + self.assertEqual(events[len(source_events):], + [('advised', name) for name in profile['weight_shards']] + + [('backend', 'begin'), ('hashed', native.LIBRARY), ('hashed', 'model.gguf')]) + self.assertEqual(len(descriptors), 3) + for name, bits in flags: + if name in profile['weight_shards']: + self.assertEqual(bits & (os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC), + os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC) + + def test_hash_config_and_dataset_failures_never_advise_and_close_retained_fds(self): + for failure in ('individual', 'aggregate', 'config', 'dataset', 'missing', 'symlink', 'fifo'): + with self.subTest(failure=failure), tempfile.TemporaryDirectory() as directory: + selected, profile = fixture(Path(directory)) + if failure == 'individual': + path = Path(selected['model_root']) / profile['weight_shards'][-1] + path.write_bytes(b'x' * path.stat().st_size) + elif failure == 'aggregate': + profile['weights']['sha256'] = '0' * 64 + elif failure == 'config': + path = Path(selected['model_root']) / 'config.json' + path.write_bytes(b'{"auto_map":{}}') + profile['files'][path.name] = path.stat().st_size + profile['hashes'][path.name] = hashlib.sha256(path.read_bytes()).hexdigest() + elif failure == 'dataset': + Path(selected['dataset_path']).write_bytes(b'[]') + else: + path = Path(selected['model_root']) / profile['weight_shards'][-1] + path.unlink() + if failure == 'symlink': path.symlink_to(Path(selected['model_root']) / profile['weight_shards'][0]) + if failure == 'fifo': os.mkfifo(path, 0o600) + with mock.patch.object(WORKER, 'model_profile', return_value=profile), \ + mock.patch.object(WORKER.os, 'posix_fadvise') as advice: + cache = WORKER.NativeSourceCache(selected) + descriptors = [] + with self.assertRaises((WORKER.JobError, OSError)), cache: + try: + WORKER.prepare_files(selected, initial_source={}, source_cache=cache) + finally: + descriptors = [cache.parent] + [entry[0] for entry in cache.shards] + self.assertFalse(cache.prepared) + cache.advise(mock.Mock()) + advice.assert_not_called() + self.assert_closed(descriptors) + + def test_pending_cancel_and_deadline_propagate_without_advice_or_reset(self): + for failure in ('cancel', 'deadline'): + with self.subTest(failure=failure), prepared() as (cache, *_), \ + controlled_pipe() as (session, writer, _), \ + mock.patch.object(WORKER.os, 'posix_fadvise') as advice: + started = session.started + if failure == 'cancel': + os.write(writer, control(1, 'cancel')) + elapsed = 0 + else: + elapsed = session.request['max_seconds'] * 1000 + 1 + with mock.patch.object(session, 'elapsed', return_value=elapsed), \ + self.assertRaisesRegex(WORKER.JobError, 'JOB_CANCELLED|JOB_DEADLINE_EXCEEDED'): + cache.advise(session) + self.assertEqual(session.started, started) + advice.assert_not_called() + + def test_success_uses_exactly_three_once_and_advice_errors_do_not_replace_result(self): + for failure in (None, OSError(errno.EPERM, 'private exception must not be exported'), AttributeError('unsupported')): + with self.subTest(failure=type(failure).__name__), prepared() as (cache, _, _, result), \ + mock.patch.object(WORKER.os, 'posix_fadvise', side_effect=failure) as advice: + original = result + cache.advise(mock.Mock()) + cache.advise(mock.Mock()) + self.assertIs(result, original) + self.assertEqual(advice.call_count, 3) + + def test_incomplete_verification_cannot_advise_even_with_three_descriptors(self): + with prepared() as (cache, *_), mock.patch.object(WORKER.os, 'posix_fadvise') as advice: + cache.prepared = False + self.assertEqual(len(cache.shards), 3) + cache.advise(mock.Mock()) + advice.assert_not_called() + + def test_changed_file_parent_mode_link_or_inventory_prevents_advice(self): + for mutation in ('replace', 'rewrite', 'symlink', 'hardlink', 'mode', 'parent_mode', 'parent_replace', 'extra'): + with self.subTest(mutation=mutation), prepared() as (cache, selected, profile, _), \ + mock.patch.object(WORKER.os, 'posix_fadvise') as advice: + root = Path(selected['model_root']) + path = root / profile['weight_shards'][0] + if mutation == 'replace': + raw = path.read_bytes() + path.unlink() + path.write_bytes(raw) + path.chmod(0o600) + elif mutation == 'rewrite': + # Metadata binding, not a second content read; make the + # identity change deterministic even on coarse-time filesystems. + path.write_bytes(b'x' * (path.stat().st_size + 1)) + elif mutation == 'symlink': + path.unlink() + path.symlink_to(root / profile['weight_shards'][1]) + elif mutation == 'hardlink': + os.link(path, root.parent / 'extra-link') + elif mutation == 'mode': + path.chmod(0o644) + elif mutation == 'parent_mode': + root.chmod(0o755) + elif mutation == 'parent_replace': + root.rename(root.parent / 'old-source') + root.mkdir(mode=0o700) + else: + (root / 'extra').write_bytes(b'not in the fixed inventory') + cache.advise(mock.Mock()) + advice.assert_not_called() + + def test_unsafe_binding_skips_advice_without_weakening_existing_validation(self): + for mutation in ('file_mode', 'parent_mode', 'foreign_file', 'foreign_parent'): + with self.subTest(mutation=mutation), tempfile.TemporaryDirectory() as directory: + selected, profile = fixture(Path(directory)) + root = Path(selected['model_root']) + path = root / profile['weight_shards'][0] + if mutation == 'file_mode': path.chmod(0o644) + if mutation == 'parent_mode': root.chmod(0o755) + foreign_inode = (path if mutation == 'foreign_file' else root).stat().st_ino + real_fstat, real_lstat = os.fstat, Path.lstat + + def metadata(info): + if mutation.startswith('foreign') and info.st_ino == foreign_inode: + fields = ('st_dev', 'st_ino', 'st_uid', 'st_mode', 'st_nlink', 'st_size', 'st_mtime_ns', 'st_ctime_ns') + return SimpleNamespace(**dict({name: getattr(info, name) for name in fields}, st_uid=os.geteuid()+1)) + return info + + with mock.patch.object(WORKER, 'model_profile', return_value=profile), \ + mock.patch.object(WORKER.os, 'fstat', side_effect=lambda fd: metadata(real_fstat(fd))), \ + mock.patch.object(Path, 'lstat', lambda path: metadata(real_lstat(path))), \ + mock.patch.object(WORKER.os, 'posix_fadvise') as advice: + with WORKER.NativeSourceCache(selected) as cache: + result = WORKER.prepare_files(selected, initial_source={}, source_cache=cache) + cache.advise(mock.Mock()) + self.assertEqual(result[4][profile['weight_shards'][0]]['sha256'], profile['hashes'][profile['weight_shards'][0]]) + advice.assert_not_called() + + def test_original_non_native_prepare_does_not_open_or_advise_retained_descriptors(self): + with tempfile.TemporaryDirectory() as directory: + selected, profile = fixture(Path(directory)) + for name in ('inference_backend', 'native_backend_root', 'native_backend_sha256'): + del selected[name] + with mock.patch.object(WORKER, 'model_profile', return_value=profile), \ + mock.patch.object(WORKER.os, 'dup', side_effect=AssertionError('legacy must not retain')), \ + mock.patch.object(WORKER.os, 'posix_fadvise', side_effect=AssertionError('legacy must not advise')): + self.assertEqual(len(WORKER.prepare_files(selected)), 5) + + def test_close_error_does_not_mask_original_failure_or_skip_remaining_descriptors(self): + owned, closed = [], [] + original_close = os.close + + def close(descriptor): + original_close(descriptor) + if descriptor in owned and descriptor not in closed: + closed.append(descriptor) + if len(closed) == 1: + raise OSError(errno.EIO, 'close may already have released the descriptor') + + with mock.patch.object(WORKER.os, 'close', side_effect=close): + with self.assertRaisesRegex(WORKER.JobError, '^ORIGINAL_FAILURE$'): + with prepared() as (cache, *_): + owned.extend([cache.parent] + [entry[0] for entry in cache.shards]) + raise WORKER.JobError('ORIGINAL_FAILURE') + self.assertCountEqual(closed, owned) + self.assert_closed(owned) + + def test_failed_dup_is_not_retried_or_a_new_verification_failure(self): + with mock.patch.object(WORKER.os, 'dup', side_effect=OSError(errno.EMFILE, 'bounded failure')) as duplicate, \ + mock.patch.object(WORKER.os, 'posix_fadvise') as advice, prepared() as (cache, _, _, result): + self.assertEqual(len(result), 5) + self.assertEqual(duplicate.call_count, 1) + self.assertEqual(cache.shards, []) + cache.advise(mock.Mock()) + advice.assert_not_called() + + def test_scope_rejected_before_any_new_file_access(self): + with tempfile.TemporaryDirectory() as directory: + selected, _ = fixture(Path(directory)) + for change in ({'inference_backend': None}, {'mode': 'train'}, {'model_profile': WORKER.QWEN_MODEL_PROFILE}): + with self.subTest(change=change), \ + mock.patch.object(WORKER.os, 'open', side_effect=AssertionError('no files before scope')), \ + self.assertRaisesRegex(WORKER.JobError, '^NATIVE_SOURCE_PROVENANCE$'): + WORKER.NativeSourceCache(dict(selected, **change)) + + +if __name__ == '__main__': + unittest.main() diff --git a/workers/volparossa-ml/test_worker_protocol.py b/workers/volparossa-ml/test_worker_protocol.py index 01edb4516..ea479267c 100644 --- a/workers/volparossa-ml/test_worker_protocol.py +++ b/workers/volparossa-ml/test_worker_protocol.py @@ -255,7 +255,8 @@ def test_large_profile_checks_exact_original_assets_and_architecture(self): source.write_text(json.dumps(dict(task_plan_input(), model_profile=WORKER.LARGE_MODEL_PROFILE))) value = WORKER.validate_request(dict(request(), mode="plan_tasks", model_profile=WORKER.LARGE_MODEL_PROFILE, model_root=str(model), output_root=str(output), dataset_path=str(source))) - def file_hash(path, expected_size=None): + def file_hash(path, expected_size=None, *, session=None): + self.assertIsNone(session) self.assertEqual(expected_size, profile["files"][path.name]) return dict(bytes=expected_size, sha256=profile["hashes"][path.name]) with mock.patch.object(WORKER, "file_hash", side_effect=file_hash): @@ -436,7 +437,8 @@ def test_private_input_identity_binds_original_bytes_without_public_metadata(sel source.write_bytes(raw) value = WORKER.validate_request(dict(request(), mode="private_infer", model_profile=WORKER.LARGE_MODEL_PROFILE, model_root=str(model), output_root=str(output), dataset_path=str(source))) - def file_hash(path, expected_size=None): + def file_hash(path, expected_size=None, *, session=None): + self.assertIsNone(session) self.assertEqual(expected_size, profile["files"][path.name]) return dict(bytes=expected_size, sha256=profile["hashes"][path.name]) with mock.patch.object(WORKER, "file_hash", side_effect=file_hash): @@ -485,7 +487,7 @@ def test_private_dispatch_has_one_real_generation_shape_no_training_and_only_eph tokens = [21, 2] if stop == "eos" else [21] * profile["new_tokens"] model, tokenizer, torch, transformers = task_planner_doubles(text, generated=tokens) value = WORKER.validate_request(dict(request(), mode="private_infer", model_profile=profile_name, output_root=str(root))) - with mock.patch.object(WORKER, "prepare_files", return_value=(root/"model", root, source, identity, files)), \ + with mock.patch.object(WORKER, "prepare_files", return_value=(root/"model", root, source, identity, files)) as prepared, \ mock.patch.object(WORKER, "configure_offline"), \ mock.patch.object(WORKER, "load_backend", return_value=(torch, transformers, mock.Mock(), WORKER.BACKENDS)) as backend, \ mock.patch.object(WORKER, "load_model", return_value=model) as loader, \ @@ -496,9 +498,11 @@ def test_private_dispatch_has_one_real_generation_shape_no_training_and_only_eph mock.patch.object(WORKER, "WIRE_OUTPUT", io.StringIO()): session = WORKER.Session(value) result = WORKER.execute_job(value, session) + prepared.assert_called_once_with(value, session=session) backend.assert_called_once_with(value["threads"], session) loader.assert_called_once_with(transformers, torch, root/"model", profile_name) - weights.assert_called_once_with(root/"model/model.safetensors", profile["files"]["model.safetensors"]) + weights.assert_called_once_with(root/"model/model.safetensors", profile["files"]["model.safetensors"], + session=session) self.assertEqual(result["mode"], "private_infer") self.assertEqual(result["dataset"], identity) self.assertEqual(result["model"], dict(id=profile["id"], revision=profile["revision"], files=files)) diff --git a/workers/volparossa-ml/worker.py b/workers/volparossa-ml/worker.py index 33450d6ae..71a35d2b5 100644 --- a/workers/volparossa-ml/worker.py +++ b/workers/volparossa-ml/worker.py @@ -25,6 +25,7 @@ import struct import sys import time +from types import SimpleNamespace VERSION = 1 MAX_REQUEST = 65536 @@ -258,7 +259,8 @@ def bounded_integer(value, low, high): def validate_request(value): required = {"version", "id", "mode", "model_root", "dataset_path", "output_root"} optional = {"steps", "threads", "max_seconds", "adapter_root", "owner_control", "model_profile", - "private_generation_diagnostics"} + "private_generation_diagnostics", "inference_backend", "native_backend_root", "native_backend_sha256", + "output_constraint", "output_constraint_tools", "native_session"} require(type(value) is dict and required <= value.keys() and value.keys() <= required | optional, "INVALID_REQUEST_FIELDS") require(type(value["version"]) is int and value["version"] == VERSION, "UNSUPPORTED_VERSION") @@ -266,6 +268,32 @@ def validate_request(value): require(value["mode"] in ("infer", "train", "plan_document", "plan_tasks", "private_infer", "private_conversation", "public_code_proposal", "aggregate_adapter"), "INVALID_JOB_MODE") profile_name = value.get("model_profile", DEFAULT_MODEL_PROFILE) model_profile(profile_name) + native_fields = {"inference_backend", "native_backend_root", "native_backend_sha256"} + if native_fields & value.keys(): + require(native_fields <= value.keys() and value["inference_backend"] == "llama_cpp_bf16_v1" + and profile_name == QWEN4B_MODEL_PROFILE and value["mode"] == "private_conversation" + and type(value["native_backend_sha256"]) is str + and re.fullmatch(r"[0-9a-f]{64}", value["native_backend_sha256"]), "INVALID_NATIVE_BACKEND_SCOPE") + if "native_session" in value: + selected = value["native_session"] + require(type(selected) is dict and selected.keys() == {"version", "turn"} + and type(selected["version"]) is int and selected["version"] == 1 + and bounded_integer(selected["turn"], 1, 128) + and native_fields <= value.keys() and value.get("owner_control") is True, + "INVALID_NATIVE_SESSION") + turn = selected["turn"] + require(value["model_root"] == "/model" and value["native_backend_root"] == "/native-backend" + and value["dataset_path"] == f"/session/turn-{turn}/input.json" + and value["output_root"] == f"/session/turn-{turn}/output", "INVALID_NATIVE_SESSION_PATH") + constraint_fields = {"output_constraint", "output_constraint_tools"} + if constraint_fields & value.keys(): + aliases = value.get("output_constraint_tools") + require(constraint_fields <= value.keys() and native_fields <= value.keys() + and value["output_constraint"] == "qwen_tagged_tool_json_v1" + and type(value["output_constraint"]) is str + and type(aliases) is list and 1 <= len(aliases) <= 32 + and aliases == [f"vp_{index}" for index in range(len(aliases))], + "INVALID_NATIVE_OUTPUT_CONSTRAINT") require("private_generation_diagnostics" not in value or (type(value["private_generation_diagnostics"]) is bool and (not value["private_generation_diagnostics"] or @@ -291,8 +319,8 @@ def validate_request(value): "AGGREGATION_EXECUTION_SCOPE") require("owner_control" not in value or type(value["owner_control"]) is bool, "INVALID_OWNER_CONTROL") - for field in ("model_root", "dataset_path", "output_root", "adapter_root"): - if field == "adapter_root" and field not in value: + for field in ("model_root", "dataset_path", "output_root", "adapter_root", "native_backend_root"): + if field in ("adapter_root", "native_backend_root") and field not in value: continue path = value[field] require(type(path) is str and 1 < len(path.encode("utf-8")) <= 4096 @@ -769,8 +797,122 @@ def plain_path(value, directory): return path, metadata -def file_hash(path, expected_size=None, maximum=None, aggregate=None): - descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW) +class NativeSourceCache: + """One bounded hint for already verified, no-longer-consumed source shards. + + Keep the actual hashing descriptors, not reopened lookalikes. This is neither + a memory measurement nor admission credit, and emits no additional records. + """ + SHARDS = tuple(f"model-{index:05d}-of-00003.safetensors" for index in range(1, 4)) + + def __init__(self, request): + require(request.get("inference_backend") == "llama_cpp_bf16_v1" + and request.get("model_profile") == QWEN4B_MODEL_PROFILE + and request.get("mode") == "private_conversation", "NATIVE_SOURCE_PROVENANCE") + self.root = Path(request["model_root"]) + profile = model_profile(QWEN4B_MODEL_PROFILE) + self.files = dict(profile["files"]) + self.parent, self.identity, self.shards = None, None, [] + self.used, self.prepared = False, False + self.valid = tuple(profile["weight_shards"]) == self.SHARDS + + @staticmethod + def identity_of(info): + return (info.st_dev, info.st_ino, info.st_uid, info.st_mode, info.st_nlink, + info.st_size, info.st_mtime_ns, info.st_ctime_ns) + + def __enter__(self): + try: + if self.valid and self.root.is_absolute() and self.root.resolve(strict=True) == self.root: + self.parent = os.open(self.root, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + | os.O_NONBLOCK | os.O_CLOEXEC) + info = os.fstat(self.parent) + self.identity = self.identity_of(info) + self.valid = (stat.S_ISDIR(info.st_mode) and info.st_uid == os.geteuid() + and stat.S_IMODE(info.st_mode) == 0o700 and self.parent_unchanged()) + else: + self.valid = False + except OSError: + self.valid = False + return self + + def __exit__(self, *_error): + descriptors = [entry[0] for entry in self.shards] + if self.parent is not None: + descriptors.append(self.parent) + self.shards, self.parent, self.valid = [], None, False + for descriptor in descriptors: + # Never retry close: even a failed close may have released this FD. + with contextlib.suppress(OSError): + os.close(descriptor) + + def parent_unchanged(self): + return (self.parent is not None and self.root.resolve(strict=True) == self.root + and self.identity_of(os.fstat(self.parent)) == self.identity + and self.identity_of(self.root.lstat()) == self.identity + and set(os.listdir(self.parent)) == set(self.files)) + + def retain(self, path, descriptor, before): + try: + index = len(self.shards) + after = os.fstat(descriptor) + identity = self.identity_of(after) + if (not self.valid or index >= 3 or path != self.root / self.SHARDS[index] + or not self.parent_unchanged() or not stat.S_ISREG(after.st_mode) + or after.st_uid != os.geteuid() or stat.S_IMODE(after.st_mode) & 0o077 + or after.st_nlink != 1 or after.st_size != self.files[self.SHARDS[index]] + or not 0 < after.st_size <= 4 * 1024 ** 3 + or self.identity_of(before) != identity or self.identity_of(path.lstat()) != identity): + self.valid = False + return + # dup retains the exact file description used for hashing; no content + # is reread, and Python creates the duplicate non-inheritable. + self.shards.append((os.dup(descriptor), self.SHARDS[index], identity)) + except OSError: + self.valid = False + + def unchanged(self, entry): + descriptor, name, identity = entry + return (self.parent_unchanged() and self.identity_of(os.fstat(descriptor)) == identity + and self.identity_of((self.root / name).lstat()) == identity) + + def advise(self, session): + if self.used: + return + self.used = True + # Owner control/deadline exceptions must propagate, never be mistaken for + # an unavailable best-effort advisory. No pause or deadline is reset. + session.check() + try: + if (not self.valid or not self.prepared or len(self.shards) != 3 + or not all(map(self.unchanged, self.shards))): + return + except OSError: + return + for entry in self.shards: + session.check() + try: + if not self.unchanged(entry): + return + os.posix_fadvise(entry[0], 0, entry[2][5], os.POSIX_FADV_DONTNEED) + if not self.unchanged(entry): + return + except (OSError, AttributeError): + # Unsupported/failed advice is not verification failure or proof + # of reclaim. Each descriptor receives at most one attempt. + continue + + +def file_hash(path, expected_size=None, maximum=None, aggregate=None, *, session=None, source_cache=None): + # Hash every original byte, but service owner controls on this execution + # thread between bounded reads. A pause is never acknowledged while a read + # or digest update is running, and does not reset the original job deadline. + if session is not None: + session.check() + flags = os.O_RDONLY | os.O_NOFOLLOW + if source_cache is not None: + flags |= os.O_NONBLOCK | os.O_CLOEXEC + descriptor = os.open(path, flags) with os.fdopen(descriptor, "rb") as source: metadata = os.fstat(source.fileno()) require(stat.S_ISREG(metadata.st_mode) and metadata.st_nlink == 1, "INVALID_ARTIFACT_FILE") @@ -778,6 +920,8 @@ def file_hash(path, expected_size=None, maximum=None, aggregate=None): require(maximum is None or metadata.st_size <= maximum, "ARTIFACT_TOO_LARGE") digest, length = hashlib.sha256(), 0 while True: + if session is not None: + session.check() data = source.read(1024 * 1024) if not data: break @@ -786,7 +930,11 @@ def file_hash(path, expected_size=None, maximum=None, aggregate=None): digest.update(data) if aggregate is not None: aggregate.update(data) + if session is not None: + session.check() require(length == metadata.st_size, "ARTIFACT_CHANGED") + if source_cache is not None: + source_cache.retain(path, source.fileno(), metadata) return {"bytes": length, "sha256": digest.hexdigest()} @@ -799,7 +947,7 @@ def read_bounded(path, maximum): return value -def verify_sharded_weights(model_root, profile, verified_files=None): +def verify_sharded_weights(model_root, profile, verified_files=None, *, session=None, source_cache=None): """Hash the original shard bytes in pinned order, never the index as weights. The existing identity wire field remains a SHA256 over actual weight bytes. @@ -807,7 +955,7 @@ def verify_sharded_weights(model_root, profile, verified_files=None): """ shards = profile["weight_shards"] index_name = "model.safetensors.index.json" - index_file = file_hash(model_root / index_name, profile["files"][index_name]) + index_file = file_hash(model_root / index_name, profile["files"][index_name], session=session) require(index_file["sha256"] == profile["hashes"][index_name], "MODEL_FILES_NOT_PINNED") if verified_files is not None: verified_files[index_name] = index_file @@ -817,7 +965,8 @@ def verify_sharded_weights(model_root, profile, verified_files=None): and set(index["weight_map"].values()) == set(shards), "INVALID_WEIGHT_SHARD_INDEX") aggregate, total = hashlib.sha256(), 0 for name in shards: - checked = file_hash(model_root / name, profile["files"][name], aggregate=aggregate) + checked = file_hash(model_root / name, profile["files"][name], aggregate=aggregate, session=session, + **({"source_cache": source_cache} if source_cache is not None else {})) require(checked["sha256"] == profile["hashes"][name], "MODEL_WEIGHTS_CHANGED_ON_DISK") if verified_files is not None: verified_files[name] = checked @@ -828,7 +977,17 @@ def verify_sharded_weights(model_root, profile, verified_files=None): return identity -def prepare_files(request): +def prepare_files(request, session=None, *, initial_source=None, source_cache=None): + # Native inference consumes a converted GGUF, not these source shards. Keep + # their actually measured initial identity without changing the existing + # five-value API or the ordinary PyTorch before/after verification path. + if initial_source is not None: + require(type(initial_source) is dict and not initial_source + and request.get("inference_backend") == "llama_cpp_bf16_v1" + and request.get("model_profile") == QWEN4B_MODEL_PROFILE + and request.get("mode") == "private_conversation", "NATIVE_SOURCE_PROVENANCE") + require(source_cache is None or (initial_source is not None and type(source_cache) is NativeSourceCache), + "NATIVE_SOURCE_PROVENANCE") profile_name = request.get("model_profile", DEFAULT_MODEL_PROFILE) profile = model_profile(profile_name) model_root, model_metadata = plain_path(request["model_root"], True) @@ -844,10 +1003,13 @@ def prepare_files(request): sharded = set(profile.get("weight_shards", ())) if sharded: sharded.add("model.safetensors.index.json") - files = {name: file_hash(model_root / name, expected_size=size) + files = {name: file_hash(model_root / name, expected_size=size, session=session) for name, size in profile["files"].items() if name not in sharded} if sharded: - verify_sharded_weights(model_root, profile, files) + measured_weights = verify_sharded_weights(model_root, profile, files, session=session, + **({"source_cache": source_cache} if source_cache is not None else {})) + if initial_source is not None: + initial_source["weights"] = dict(measured_weights, files=list(measured_weights["files"])) require(all(files[name]["sha256"] == expected for name, expected in profile["hashes"].items()), "MODEL_FILES_NOT_PINNED") config = parse_json(read_bounded(model_root / "config.json", 8192)) @@ -862,6 +1024,8 @@ def prepare_files(request): if request["mode"] in ("private_infer", "private_conversation"): identity = {"sha256": hashlib.sha256(raw_dataset).hexdigest(), "bytes": len(raw_dataset), "visibility": "private_local"} + if source_cache is not None: + source_cache.prepared = True return model_root, output_root, dataset, identity, files identity = { "sha256": hashlib.sha256(raw_dataset).hexdigest(), "bytes": len(raw_dataset), @@ -1961,6 +2125,22 @@ def conversation_module(): return module +def conversation_constraint_binding(request, dataset, profile_name): + requested = "output_constraint" in request + require(requested == ("output_constraint" in dataset), "NATIVE_OUTPUT_CONSTRAINT_BINDING") + if not requested: + require("output_constraint_tools" not in request, "NATIVE_OUTPUT_CONSTRAINT_BINDING") + return None + conversation_module().validate(dataset, profile_name) + constraint = conversation_module().output_constraint(dataset, profile_name) + require(request["output_constraint"] == constraint + and request.get("inference_backend") == "llama_cpp_bf16_v1", + "NATIVE_OUTPUT_CONSTRAINT_BINDING") + aliases = [f"vp_{index}" for index in range(len(dataset["tools"]))] + require(request.get("output_constraint_tools") == aliases, "NATIVE_OUTPUT_CONSTRAINT_TOOLS") + return constraint + + def execute_private_infer(request, session, tokenizer, torch, transformers, versions, model_root, output_root, dataset, data_identity, model_files): profile_name = request.get("model_profile", DEFAULT_MODEL_PROFILE) @@ -1990,10 +2170,10 @@ def execute_private_infer(request, session, tokenizer, torch, transformers, vers session.private_progress("generation", "complete") session.private_progress("verify_after", "begin") if "weight_shards" in profile: - weight_identity = verify_sharded_weights(model_root, profile) + weight_identity = verify_sharded_weights(model_root, profile, session=session) else: weight_identity = None - require(file_hash(model_root / "model.safetensors", profile["files"]["model.safetensors"])["sha256"] + require(file_hash(model_root / "model.safetensors", profile["files"]["model.safetensors"], session=session)["sha256"] == profile["hashes"]["model.safetensors"], "MODEL_WEIGHTS_CHANGED_ON_DISK") session.private_progress("verify_after", "complete") result = {"version": VERSION, "id": request["id"], "kind": "result", "status": "ok", "mode": request["mode"], @@ -2030,10 +2210,10 @@ def execute_code_proposal(request, session, tokenizer, torch, transformers, vers outputs = generate(model, samples, tokenizer, torch, session, transformers, profile_name, generation_policy="greedy_v1") if "weight_shards" in profile: - weight_identity = verify_sharded_weights(model_root, profile) + weight_identity = verify_sharded_weights(model_root, profile, session=session) else: weight_identity = None - require(file_hash(model_root / "model.safetensors", profile["files"]["model.safetensors"])["sha256"] + require(file_hash(model_root / "model.safetensors", profile["files"]["model.safetensors"], session=session)["sha256"] == profile["hashes"]["model.safetensors"], "MODEL_WEIGHTS_CHANGED_ON_DISK") result = {"version": VERSION, "id": request["id"], "kind": "result", "status": "ok", "mode": "public_code_proposal", "purpose": "code_proposal", @@ -2286,16 +2466,27 @@ def save_checkpoint(model, output_root, session): return adapter_root, artifacts -def execute_job(request, session): +def execute_job(request, session, *, native_session=None): + require((native_session is not None) == ("native_session" in request), "INVALID_NATIVE_SESSION") profile_name = request.get("model_profile", DEFAULT_MODEL_PROFILE) profile = model_profile(profile_name) session.private_progress("owner_gate", "begin") session.progress("preparing") session.private_progress("owner_gate", "complete") session.private_progress("verify_files", "begin") - model_root, output_root, dataset, data_identity, model_files = prepare_files(request) - session.private_progress("verify_files", "complete") - session.check() + initial_source = {} + with contextlib.ExitStack() as source_lifetime: + source_cache = (source_lifetime.enter_context(NativeSourceCache(request)) + if request.get("inference_backend") == "llama_cpp_bf16_v1" else None) + preparation = ({"initial_source": initial_source, "source_cache": source_cache} + if source_cache is not None else {}) + model_root, output_root, dataset, data_identity, model_files = prepare_files(request, session=session, **preparation) + if request["mode"] == "private_conversation": + conversation_constraint_binding(request, dataset, profile_name) + session.private_progress("verify_files", "complete") + session.check() + if source_cache is not None: + source_cache.advise(session) cohort = None if request["mode"] == "aggregate_adapter": root, metadata = plain_path(request["adapter_root"], True) @@ -2308,20 +2499,29 @@ def execute_job(request, session): configure_offline() session.check() session.private_progress("backend_import", "begin") - torch, transformers, peft, versions = load_backend(request["threads"], session) + torch, transformers, peft, versions = (load_backend(request["threads"], session) + if native_session is None else native_session.backend(request["threads"], session)) session.private_progress("backend_import", "complete") session.check() if cohort is not None: return execute_aggregation(request, session, torch, versions, output_root, data_identity, model_files, cohort) session.private_progress("tokenizer_load", "begin") - tokenizer = transformers.AutoTokenizer.from_pretrained( + tokenizer = (transformers.AutoTokenizer.from_pretrained( str(model_root), local_files_only=True, trust_remote_code=False, use_fast=True) + if native_session is None else native_session.tokenizer(transformers, model_root)) expected_pad, expected_eos = (151643, 151645) if profile_name in NATIVE_CONVERSATION_PROFILES else (2, 2) require(tokenizer.pad_token_id == expected_pad and tokenizer.eos_token_id == expected_eos, "MODEL_TOKENIZER_MISMATCH") session.private_progress("tokenizer_load", "complete") session.check() + if request.get("inference_backend") == "llama_cpp_bf16_v1": + native_backend = sys.modules.get("volparossa_llama_cpu") + require(native_backend is not None, "NATIVE_BACKEND_MODULE_UNAVAILABLE") + warm = {} if native_session is None else {"retained": native_session.model} + return native_backend.execute(request, session, tokenizer, torch, versions, model_root, output_root, + dataset, data_identity, model_files, SimpleNamespace(**globals()), + initial_source_weights=initial_source.get("weights"), **warm) if request["mode"] == "plan_document": plan = plan_document(tokenizer, dataset, session, profile_name) raw = json.dumps(plan, ensure_ascii=True, allow_nan=False, sort_keys=True, separators=(",", ":")).encode("ascii") @@ -2494,6 +2694,10 @@ def stop_requested(_signum, _frame): STOP_REQUESTED = True +def is_stopping(): + return STOP_REQUESTED + + def main(): os.umask(0o077) signal.signal(signal.SIGTERM, stop_requested) @@ -2509,6 +2713,10 @@ def main(): if type(value) is dict and type(value.get("id")) is str and HEX32.fullmatch(value["id"]): request_id = value["id"] request = validate_request(value) + if "native_session" in request: + runtime = sys.modules.get("volparossa_native_session") + require(runtime is not None, "NATIVE_SESSION_MODULE_UNAVAILABLE") + return runtime.run(request, frames, SimpleNamespace(**globals())) session = Session(request, frames if controlled else None) # Third-party diagnostics may include local paths. Only our fixed, bounded protocol # uses the original stdout; no raw prompt, dataset or stack trace is logged.