From 29f7d5c7fc4e03ffef8d8183c7169f0e3247bd17 Mon Sep 17 00:00:00 2001 From: mohmd Date: Sat, 10 Oct 2026 13:20:11 +0000 Subject: [PATCH 1/2] feat: add Krylov inference application and interactive Pages lab --- .github/workflows/krylov.yml | 42 + .github/workflows/pages.yml | 50 ++ .gitignore | 9 +- README.md | 28 +- applications/krylov/README.md | 67 ++ applications/krylov/__init__.py | 21 + applications/krylov/__main__.py | 97 +++ applications/krylov/ckks.py | 218 +++++ applications/krylov/core.py | 148 ++++ applications/krylov/demo.py | 138 +++ docs/krylov.md | 97 +++ package-lock.json | 853 +++++++++++++++++++ package.json | 20 + playwright.config.js | 20 + requirements-ckks.txt | 2 + site/app.js | 349 ++++++++ site/ckks.js | 322 +++++++ site/ckks.test.mjs | 49 ++ site/data/demo.json | 609 ++++++++++++++ site/index.html | 148 ++++ site/krylov.js | 229 +++++ site/krylov.test.mjs | 93 +++ site/styles.css | 1381 +++++++++++++++++++++++++++++++ tests/browser/krylov.spec.js | 117 +++ tests/test_krylov.py | 261 ++++++ vite.config.js | 17 + 26 files changed, 5374 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/krylov.yml create mode 100644 .github/workflows/pages.yml create mode 100644 applications/krylov/README.md create mode 100644 applications/krylov/__init__.py create mode 100644 applications/krylov/__main__.py create mode 100644 applications/krylov/ckks.py create mode 100644 applications/krylov/core.py create mode 100644 applications/krylov/demo.py create mode 100644 docs/krylov.md create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 playwright.config.js create mode 100644 requirements-ckks.txt create mode 100644 site/app.js create mode 100644 site/ckks.js create mode 100644 site/ckks.test.mjs create mode 100644 site/data/demo.json create mode 100644 site/index.html create mode 100644 site/krylov.js create mode 100644 site/krylov.test.mjs create mode 100644 site/styles.css create mode 100644 tests/browser/krylov.spec.js create mode 100644 tests/test_krylov.py create mode 100644 vite.config.js diff --git a/.github/workflows/krylov.yml b/.github/workflows/krylov.yml new file mode 100644 index 0000000..dc8ee83 --- /dev/null +++ b/.github/workflows/krylov.yml @@ -0,0 +1,42 @@ +name: Krylov application and browser lab + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + python: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + - run: pip install numpy pytest -r requirements-ckks.txt + # These standalone NumPy tests do not use the legacy Cora fixtures. + - run: python -m pytest --noconftest -q tests/test_krylov.py + - run: python -m applications.krylov --backend plain --degree 2 --output /tmp/krylov-plain.json + - run: python -m applications.krylov --backend ckks --degree 2 --output /tmp/krylov-ckks.json + + browser: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + - run: npm ci + - run: npm test + - run: npm run build + - run: npx playwright install --with-deps chromium + - run: npm run test:browser + - uses: actions/upload-artifact@v4 + with: + name: krylov-browser-lab + path: dist diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 0000000..361fb24 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,50 @@ +name: Publish PrivnetAI Pages + +on: + push: + branches: [main] + paths: + - 'site/**' + - 'package*.json' + - 'vite.config.js' + - '.github/workflows/pages.yml' + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: github-pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + - run: npm ci + - run: npm test + - run: npm run build + - run: npx playwright install --with-deps chromium + - run: npm run test:browser + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v3 + with: + path: dist + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy tested Pages artifact + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 0c49fe1..fafddf0 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,11 @@ .venv .venv/ __pycache__/ -data/ \ No newline at end of file +data/ +!site/data/ +!site/data/demo.json +node_modules/ +dist/ +playwright-report/ +test-results/ +krylov-results.json diff --git a/README.md b/README.md index 111e137..01d7a13 100644 --- a/README.md +++ b/README.md @@ -7,16 +7,22 @@ Welcome to **PrivNet.AI** β€” an open-source platform combining **post-quantum c ## πŸš€ Project Vision Build a privacy-centric infrastructure where users can train graph-based models **without ever decrypting their data**. -We aim to use **isogeny-based cryptography** (post-quantum secure) and **graph neural networks (GNNs)** to perform secure, structure-aware learning on encrypted data. +We explore **lattice-based homomorphic encryption** and **graph neural networks (GNNs)** for structure-aware inference on encrypted data. Authenticated post-quantum communication and encrypted training remain separate future protocol work. -> **Current Status**: This is a proof-of-concept (PoC) demonstrating encrypted linear operations on graph neural network features. The current implementation uses `EncryptionShim` (a cryptographic simulator) for demonstration purposes. See [Limitations](#-current-limitations) below. +> **Current Status**: The original GCN proof of concept uses `EncryptionShim`, an educational simulator. The separate Krylov application supports real CKKS inference on a small demonstration model. Encrypted training is not implemented. See [Limitations](#-current-limitations) and [Krylov boundaries](docs/krylov.md). + +## Interactive Krylov application + +The [Krylov lab](https://aliaslany.github.io/PrivnetAI/) adds client-side graph compilation, interactive predictions, and an optional real-CKKS demonstration in the browser. It implements the method from [PrivNet-Krylov](https://github.com/chimans/PrivNet-Krylov) independently, with attribution to Ehsan Hakimian and Chiman Soltanian. The sample is a public synthetic graph; displayed accuracy is illustrative all-node accuracy. + +Run the application locally with `python -m applications.krylov --backend plain --output /tmp/krylov.json`. For real encrypted execution, install `requirements-ckks.txt` and select `--backend ckks`. See [Krylov usage and boundaries](docs/krylov.md) for the model, browser development, serialized key boundary and validation commands. --- ## 🧩 Key Technologies - **Graph Neural Networks** for structured data learning (PyTorch Geometric) -- **Post-Quantum Cryptography** (PQC) modules for future isogeny-based encryption -- **Homomorphic Encryption** operations framework (in development) +- **Post-Quantum Cryptography** (PQC) protocol integration (planned; current module is a mock) +- **Homomorphic Encryption** through optional CKKS backends for the Krylov application - **PyTorch** for deep learning infrastructure - **Federated Learning & Differential Privacy** (planned for future integration) @@ -126,6 +132,7 @@ Encrypted linear head test acc: 0.492 ## πŸ“š Documentation - [roadmap.md](roadmap.md) – Project roadmap and development phases +- [Krylov application](docs/krylov.md) – Client-side compilation, real CKKS, and interactive GitHub Pages lab - [Research archive (10 October 2026)](docs/research/2026-10-10/README.md) – 2025–2026 literature findings, numerical results, and a proposed roadmap review - [README_INSTRUCTION.md](README_INSTRUCTION.md) – Detailed PoC documentation - [CONTRIBUTING.md](CONTRIBUTING.md) – Contribution guidelines @@ -133,7 +140,7 @@ Encrypted linear head test acc: 0.492 --- -## πŸ”¬ Current Implementation Details +## πŸ”¬ Original GCN Proof-of-Concept Details ### Architecture Overview The current PoC demonstrates: @@ -215,9 +222,9 @@ We welcome contributions from cryptographers, ML engineers, researchers, and dev --- ## πŸ“ Project Status -We're in **early development** β€” currently building a proof-of-concept demonstrating encrypted linear operations on GNN features. +We’re in **early development**, with the original GCN simulator and a separate Krylov application offering optional real CKKS inference. -**Current Phase**: PoC with EncryptionShim simulator +**Current Phase**: GCN simulator plus experimental Krylov CKKS inference **Next Steps**: See [roadmap.md](roadmap.md) for detailed development phases Use `Issues` to suggest features or `Discussions` to brainstorm with us. @@ -231,11 +238,12 @@ See [roadmap.md](roadmap.md) for the complete development plan. - [x] Repo bootstrapping & structure setup - [x] PoC with EncryptionShim and GCN on Planetoid datasets - [x] Basic test suite +- [x] Independent Krylov application and interactive browser lab with optional real CKKS **In Progress / Planned:** -- [ ] Real homomorphic encryption integration (CKKS/BFV) -- [ ] Full encrypted inference pipeline -- [ ] Post-quantum cryptography (isogeny-based) +- [ ] Broader homomorphic backend and model integration +- [ ] Full encrypted inference for the original GCN architecture +- [ ] Authenticated post-quantum communication - [ ] Encrypted graph structure support - [ ] Federated learning integration - [ ] MVP deployment diff --git a/applications/krylov/README.md b/applications/krylov/README.md new file mode 100644 index 0000000..4497eed --- /dev/null +++ b/applications/krylov/README.md @@ -0,0 +1,67 @@ +# Independent Krylov demonstration + +This application implements client-side Chebyshev compilation and polynomial +classification independently, inspired by the published mathematical approach +in [PrivNet-Krylov](https://github.com/chimans/PrivNet-Krylov), inspected at commit +`0976e41881192da938ef2ab988c2fe3fdb92835b`. The upstream repository has no license; +none of its code, artifacts, figures, timings, or benchmark claims are copied. + +From the repository root: + +```bash +python -m applications.krylov --backend plain --degree 2 \ + --output /tmp/krylov-plain.json --export-demo /tmp/krylov-demo.json +pip install -r requirements-ckks.txt +python -m applications.krylov --backend ckks --degree 2 \ + --output /tmp/krylov-ckks.json +``` + +NumPy is the only mandatory application dependency. TenSEAL 0.3.16 is optional; +requesting CKKS without it exits with a clear error rather than simulating HE. +The existing project environment can run these commands with `.venv/bin/python`. +Use `--fixture /tmp/krylov-demo.json` to evaluate an existing fixture with the +same schema. Demo import is bounded to 1 MiB, 120 nodes, 32 feature/hidden +channels and 16 classes. Input, result and exported-fixture paths must differ. + +The client normalizes an undirected graph as +`S = D^-1/2 (A + I) D^-1/2` and compiles `B0 = X`, `B1 = SX`, and +`Bk = 2 S B{k-1} - B{k-2}`. It then evaluates +`Z = sum(Bk theta_k) + bias`, `H = ZΒ²`, and `logits = H W + output_bias`. +The complete degree-0-through-4 bank and one fixed model are exported for the +browser. Selecting a degree truncates the bank and weights; it does not retrain. +Exported edges are unique ordered pairs, interpreted symmetrically; the +normalization adds a self loop even at an isolated node. + +The deterministic 24-node graph has two communities and noisy features. Two +regularized least-squares fits train the classifier on **all nodes**, using the +degree-4 bank. All reported accuracy is illustrative training/all-node accuracy. +There is no held-out split, comparison with the upstream results, or novelty +claim. The browser fixture includes all features, edges and labels publicly, and +browser evaluation uses plaintext arithmetic. + +The optional CKKS backend packs each block/feature column across nodes. The +client generates keys, encrypts the columns and serializes a context without a +secret key. `evaluate_server` deserializes that public context and ciphertexts, +executes the affine/square/readout circuit, and returns ciphertext bytes. +Only `ClientSession.decrypt` holds the secret key. The local runner orchestrates +both roles in one process, demonstrating an actual serialization boundary +rather than claiming a deployed remote protocol. The server does not receive +the adjacency, plaintext features or labels, but sees shapes, degree, the clear +model and message sizes. Client compilation and upload costs remain relevant. + +The fixed parameters are degree 16384, coefficient-modulus sizes +`[60, 40, 40, 40, 40, 60]`, and scale `2^40`. They pass the backend's default +SEAL classical parameter bound. There is **no independent security estimate or +post-quantum security claim**. The circuit uses depth three and no rotations; +only the square activation is supported by this CKKS runner. Authentication, +malicious-server integrity, decryption-oracle protections, padding, key lifecycle +and transport are outside this demonstration. + +Results distinguish executed plaintext from executed genuine CKKS, and record +same-model accuracy/agreement, numerical errors, parameters, serialized sizes +and individual timing stages. A result is created only after the selected +backend succeeds. Reproduce the focused checks with: + +```bash +python -m pytest -q tests/test_krylov.py +``` diff --git a/applications/krylov/__init__.py b/applications/krylov/__init__.py new file mode 100644 index 0000000..744b431 --- /dev/null +++ b/applications/krylov/__init__.py @@ -0,0 +1,21 @@ +"""Independent client-side Chebyshev compilation and polynomial inference. + +Mathematical inspiration: https://github.com/chimans/PrivNet-Krylov, +commit 0976e41881192da938ef2ab988c2fe3fdb92835b. No upstream code is used. +""" + +from .core import ( + MAX_DEGREE, + compile_chebyshev_blocks, + evaluate_affine, + evaluate_model, + evaluate_polynomial, + evaluate_readout, + normalize_adjacency, +) + +__all__ = [ + "MAX_DEGREE", "compile_chebyshev_blocks", "evaluate_affine", + "evaluate_model", "evaluate_polynomial", "evaluate_readout", + "normalize_adjacency", +] diff --git a/applications/krylov/__main__.py b/applications/krylov/__main__.py new file mode 100644 index 0000000..abf1b2a --- /dev/null +++ b/applications/krylov/__main__.py @@ -0,0 +1,97 @@ +"""Run the public synthetic demonstration with plaintext or genuine CKKS.""" + +import argparse +import json +from pathlib import Path +import platform +import sys +import time + +import numpy as np + +from .core import MAX_DEGREE, compile_chebyshev_blocks, evaluate_affine, evaluate_model, normalize_adjacency +from .demo import DEFAULT_DEGREE, fixture_arrays, make_demo + + +def write_json(path: Path, value): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(value, indent=2, allow_nan=False) + "\n", encoding="utf-8") + + +def run(args) -> int: + if args.fixture: + if args.fixture.stat().st_size > 1024 * 1024: + raise ValueError("fixture JSON must be at most 1 MiB") + fixture = json.loads(args.fixture.read_text(encoding="utf-8")) + else: + fixture = make_demo() + adjacency, features, labels = fixture_arrays(fixture) + degree = fixture["default_degree"] if args.degree is None else args.degree + if degree >= len(fixture["model"]["theta"]): + raise ValueError("selected degree exceeds the fixture theta bank") + start = time.perf_counter() + blocks = compile_chebyshev_blocks(normalize_adjacency(adjacency), features, degree) + compile_seconds = time.perf_counter() - start + start = time.perf_counter() + reference = evaluate_model(blocks, fixture["model"], degree) + plain_seconds = time.perf_counter() - start + details = {"plaintext_evaluation_seconds": plain_seconds} + logits = reference + if args.backend == "ckks": + from .ckks import run_ckks + + logits, details = run_ckks(blocks, fixture["model"], degree) + affine = evaluate_affine(blocks, fixture["model"]["theta"], fixture["model"]["bias"], degree) + predictions = logits.argmax(axis=1) + reference_predictions = reference.argmax(axis=1) + result = { + "schema_version": 1, "application": "PrivnetAI independent Krylov demonstration", + "backend": args.backend, "measurement_kind": "executed real CKKS" if args.backend == "ckks" else "executed plaintext", + "degree": degree, "nodes": len(labels), "features": features.shape[1], + "hidden_channels": affine.shape[1], "classes": logits.shape[1], + "accuracy_scope": fixture["provenance"].get("evaluation_scope", "Illustrative all-node accuracy; no held-out split evaluated"), + "all_node_accuracy": float(np.mean(predictions == labels)), + "plaintext_all_node_accuracy": float(np.mean(reference_predictions == labels)), + "prediction_agreement_with_same_plain_model": float(np.mean(predictions == reference_predictions)), + "max_absolute_logit_error": float(np.max(np.abs(logits - reference))), + "mean_absolute_logit_error": float(np.mean(np.abs(logits - reference))), + "observed_affine_range": [float(affine.min()), float(affine.max())], + "client_compilation_seconds": compile_seconds, + "predictions": predictions.tolist(), "logits": logits.tolist(), + "provenance": fixture["provenance"], + "environment": {"python": platform.python_version(), "numpy": np.__version__, + "platform": platform.platform(), "machine": platform.machine()}, + "details": details, + } + write_json(args.output, result) + if args.export_demo: + write_json(args.export_demo, fixture) + print(json.dumps({"backend": args.backend, "degree": degree, + "all_node_accuracy": result["all_node_accuracy"], + "accuracy_scope": result["accuracy_scope"], + "same_model_agreement": result["prediction_agreement_with_same_plain_model"], + "max_absolute_logit_error": result["max_absolute_logit_error"], + "output": str(args.output)}, indent=2)) + return 0 + + +def main(argv=None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--backend", choices=("plain", "ckks"), default="plain") + parser.add_argument("--degree", type=int, choices=range(MAX_DEGREE + 1), + help=f"Truncation degree; defaults to fixture default_degree ({DEFAULT_DEGREE} for the synthetic demo)") + parser.add_argument("--fixture", type=Path, help="Optional public browser-schema fixture JSON, at most 1 MiB") + parser.add_argument("--output", type=Path, default=Path("krylov-results.json"), help="Result JSON file") + parser.add_argument("--export-demo", type=Path, help="Export public browser fixture JSON") + args = parser.parse_args(argv) + paths = [path.resolve() for path in (args.fixture, args.output, args.export_demo) if path] + if len(paths) != len(set(paths)): + parser.error("fixture, output and export-demo must use distinct paths") + try: + return run(args) + except (OSError, UnicodeError, ValueError, ImportError, RuntimeError) as exc: + parser.exit(2, f"Krylov run failed: {exc}\n") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/applications/krylov/ckks.py b/applications/krylov/ckks.py new file mode 100644 index 0000000..e24aee3 --- /dev/null +++ b/applications/krylov/ckks.py @@ -0,0 +1,218 @@ +"""Optional genuine TenSEAL execution with a serialized client/server boundary. + +The server API accepts a public context, encrypted compiled columns and a clear +model. It has no graph, plaintext feature input, labels or secret key. The local +runner orchestrates both roles in one process; it is not a network protocol. +""" + +from dataclasses import dataclass +import time + +import numpy as np + +from .core import finite_array, model_arrays, validate_degree + +POLY_MODULUS_DEGREE = 16384 +COEFF_MOD_BIT_SIZES = (60, 40, 40, 40, 40, 60) +GLOBAL_SCALE = 2.0 ** 40 + + +def _tenseal(): + try: + import tenseal as ts + except ImportError as exc: + raise ImportError("install optional dependencies with pip install -r requirements-ckks.txt") from exc + return ts + + +@dataclass(frozen=True) +class EncryptedRequest: + public_context: bytes + block_columns: tuple[tuple[bytes, ...], ...] + degree: int + node_count: int + feature_count: int + + @property + def ciphertext_bytes(self) -> int: + return sum(len(column) for block in self.block_columns for column in block) + + +@dataclass +class ClientSession: + """Private context remains solely in this client-side object.""" + + context: object + + def decrypt(self, response: tuple[bytes, ...]) -> np.ndarray: + ts = _tenseal() + if not self.context.has_secret_key(): + raise ValueError("client decryption requires its private context") + values = [ts.ckks_vector_from(self.context, ciphertext).decrypt() for ciphertext in response] + return np.asarray(values, dtype=np.float64).T + + +def client_encrypt_blocks(blocks, degree: int) -> tuple[ClientSession, EncryptedRequest, dict]: + ts = _tenseal() + degree = validate_degree(degree) + blocks = finite_array(blocks, "blocks", 3) + if blocks.shape[0] <= degree: + raise ValueError("degree exceeds available client blocks") + if blocks.shape[1] > POLY_MODULUS_DEGREE // 2: + raise ValueError("demo backend supports at most 8192 nodes per packed column") + start = time.perf_counter() + context = ts.context( + ts.SCHEME_TYPE.CKKS, poly_modulus_degree=POLY_MODULUS_DEGREE, + coeff_mod_bit_sizes=list(COEFF_MOD_BIT_SIZES), n_threads=2, + ) + context.global_scale = GLOBAL_SCALE + context.generate_relin_keys() + public_context = context.serialize( + save_public_key=True, save_secret_key=False, + save_galois_keys=False, save_relin_keys=True, + ) + setup_seconds = time.perf_counter() - start + start = time.perf_counter() + encrypted = tuple( + tuple(ts.ckks_vector(context, blocks[k, :, f].tolist()).serialize() + for f in range(blocks.shape[2])) + for k in range(degree + 1) + ) + encryption_seconds = time.perf_counter() - start + request = EncryptedRequest(public_context, encrypted, degree, blocks.shape[1], blocks.shape[2]) + return ClientSession(context), request, { + "context_setup_and_public_serialization_seconds": setup_seconds, + "client_encryption_and_serialization_seconds": encryption_seconds, + } + + +def evaluate_server(request: EncryptedRequest, model) -> tuple[tuple[bytes, ...], dict]: + """Deserialize public inputs, evaluate a square model, return ciphertexts. + + Only the square activation is supported by this intentionally small CKKS + circuit. There is no fallback to plaintext or simulated encryption. + """ + ts = _tenseal() + degree = validate_degree(request.degree) + model = model_arrays(model) + theta, bias = model["theta"], model["bias"] + weights, output_bias = model["output_weights"], model["output_bias"] + if not np.array_equal(model["activation_coefficients"], [0.0, 0.0, 1.0]): + raise ValueError("CKKS demo requires the square activation [0, 0, 1]") + if len(request.block_columns) != degree + 1 or theta.shape[0] <= degree: + raise ValueError("encrypted block count does not match the selected degree") + if request.feature_count != theta.shape[1] or any( + len(columns) != request.feature_count for columns in request.block_columns + ): + raise ValueError("encrypted feature width does not match the model") + if not 0 < request.node_count <= POLY_MODULUS_DEGREE // 2: + raise ValueError("invalid packed node count") + start = time.perf_counter() + server_context = ts.context_from(request.public_context, n_threads=2) + if server_context.has_secret_key() or not server_context.is_public(): + raise ValueError("server context must contain no secret key") + encrypted = [[ts.ckks_vector_from(server_context, column) for column in columns] + for columns in request.block_columns] + if any(vector.size() != request.node_count for columns in encrypted for vector in columns): + raise ValueError("encrypted column length does not match node count") + context_seconds = time.perf_counter() - start + start = time.perf_counter() + scalar_products = 0 + additions = 0 + bias_additions = 0 + public_key_encryptions = 0 + hidden = [] + for channel in range(bias.size): + total = None + for k in range(degree + 1): + for feature in range(request.feature_count): + coefficient = float(theta[k, feature, channel]) + if coefficient == 0: + continue + term = encrypted[k][feature] * coefficient + scalar_products += 1 + if total is None: + total = term + else: + total = total + term + additions += 1 + if total is None: + # Multiplication by zero can produce a transparent ciphertext that + # SEAL rejects. Encrypt the known constant with the public key. + total = ts.ckks_vector(server_context, [float(bias[channel])] * request.node_count) + public_key_encryptions += 1 + else: + total = total + float(bias[channel]) + bias_additions += 1 + hidden.append(total.square()) + result = [] + for output_class in range(output_bias.size): + total = None + for channel in range(bias.size): + coefficient = float(weights[channel, output_class]) + if coefficient == 0: + continue + term = hidden[channel] * coefficient + scalar_products += 1 + if total is None: + total = term + else: + total = total + term + additions += 1 + if total is None: + total = ts.ckks_vector(server_context, [float(output_bias[output_class])] * request.node_count) + public_key_encryptions += 1 + else: + total = total + float(output_bias[output_class]) + bias_additions += 1 + result.append(total.serialize()) + evaluation_seconds = time.perf_counter() - start + return tuple(result), { + "server_deserialization_seconds": context_seconds, + "server_evaluation_and_serialization_seconds": evaluation_seconds, + "server_context_has_secret_key": server_context.has_secret_key(), + "operations": { + "ciphertext_plaintext_scalar_multiplications": scalar_products, + "ciphertext_ciphertext_squares": bias.size, + "ciphertext_additions": additions, + "plaintext_bias_additions": bias_additions, + "public_key_encryptions_of_constant_columns": public_key_encryptions, + "rotations": 0, + "graph_dependent_server_ciphertext_products": 0, + "multiplicative_depth_upper_bound": 3, + }, + } + + +def run_ckks(blocks, model, degree: int) -> tuple[np.ndarray, dict]: + session, request, timings = client_encrypt_blocks(blocks, degree) + response, server_details = evaluate_server(request, model) + start = time.perf_counter() + logits = session.decrypt(response) + decryption_seconds = time.perf_counter() - start + if logits.shape != (request.node_count, model_arrays(model)["output_bias"].size): + raise ValueError("client received an unexpected output shape") + if not np.isfinite(logits).all(): + raise ValueError("CKKS output is not finite") + return logits, { + **timings, **server_details, + "client_deserialization_and_decryption_seconds": decryption_seconds, + "tenseal_version": _tenseal().__version__, + "parameters": { + "poly_modulus_degree": POLY_MODULUS_DEGREE, + "coeff_mod_bit_sizes": list(COEFF_MOD_BIT_SIZES), + "global_scale": GLOBAL_SCALE, + "library_parameter_validation": "SEAL default 128-bit classical parameter bound", + "independent_security_estimate": None, + "post_quantum_security_claim": False, + }, + "serialization_bytes": { + "public_evaluation_context": len(request.public_context), + "encrypted_input_columns": request.ciphertext_bytes, + "encrypted_output_columns": sum(map(len, response)), + "total_upload": len(request.public_context) + request.ciphertext_bytes, + }, + "boundary": "Serialized public context and ciphertexts; client-only decryption; local roles share one process", + "server_public_information": ["node/feature counts", "selected degree", "model", "ciphertext sizes"], + "security_limitations": "No malicious-server integrity, output-oracle defense, independent security estimate, or deployment protocol is claimed", + } diff --git a/applications/krylov/core.py b/applications/krylov/core.py new file mode 100644 index 0000000..72315bf --- /dev/null +++ b/applications/krylov/core.py @@ -0,0 +1,148 @@ +"""NumPy implementation of published Chebyshev and affine operations.""" + +from collections.abc import Mapping + +import numpy as np + +MAX_DEGREE = 4 + + +def validate_degree(degree: int) -> int: + if isinstance(degree, (bool, np.bool_)) or not isinstance(degree, (int, np.integer)): + raise ValueError("degree must be an integer from 0 through 4") + if not 0 <= degree <= MAX_DEGREE: + raise ValueError("degree must be an integer from 0 through 4") + return int(degree) + + +def finite_array(value, name: str, ndim: int) -> np.ndarray: + try: + raw = np.asarray(value) + if raw.dtype.kind not in "iuf": + raise ValueError("non-real or nonnumeric input") + result = raw.astype(np.float64) + except (TypeError, ValueError) as exc: + raise ValueError(f"{name} must be a rectangular numerical array") from exc + if result.ndim != ndim or any(size == 0 for size in result.shape): + raise ValueError(f"{name} must be a nonempty {ndim}-dimensional array") + if not np.isfinite(result).all(): + raise ValueError(f"{name} must contain only finite values") + return result + + +def normalize_adjacency(adjacency) -> np.ndarray: + """Return S = D^-1/2 (A + I) D^-1/2 for an undirected graph. + + Nonnegative symmetric weights are supported. Self loops are added here, + including at isolated nodes. The demo artifact supplies each unweighted + undirected edge once and excludes preexisting self loops. + """ + adjacency = finite_array(adjacency, "adjacency", 2) + if adjacency.shape[0] != adjacency.shape[1]: + raise ValueError("adjacency must be square") + if np.any(adjacency < 0) or not np.allclose(adjacency, adjacency.T, atol=1e-12, rtol=0): + raise ValueError("adjacency must be nonnegative and symmetric") + with np.errstate(over="ignore", invalid="ignore"): + looped = adjacency + np.eye(adjacency.shape[0]) + degrees = looped.sum(axis=1) + reciprocal = 1.0 / np.sqrt(degrees) + shift = reciprocal[:, None] * looped * reciprocal[None, :] + if not np.isfinite(degrees).all() or not np.isfinite(shift).all(): + raise ValueError("adjacency normalization overflowed") + return shift + + +def compile_chebyshev_blocks(shift, features, degree: int = MAX_DEGREE) -> np.ndarray: + """Compile [X, SX, 2S(SX)-X, ...] locally; shape is (K+1, N, F). + + S is the normalized adjacency, with spectrum in [-1, 1]. This is a + Chebyshev polynomial bank, not independent per-hop message passing. + """ + degree = validate_degree(degree) + shift = finite_array(shift, "shift", 2) + features = finite_array(features, "features", 2) + if shift.shape != (features.shape[0], features.shape[0]): + raise ValueError("shift must be square with one row per feature node") + if not np.allclose(shift, shift.T, atol=1e-12, rtol=0): + raise ValueError("shift must be symmetric") + blocks = [features.copy()] + if degree: + blocks.append(shift @ features) + with np.errstate(over="ignore", invalid="ignore"): + for _ in range(2, degree + 1): + blocks.append(2.0 * shift @ blocks[-1] - blocks[-2]) + result = np.stack(blocks) + if not np.isfinite(result).all(): + raise ValueError("Chebyshev compilation overflowed") + return result + + +def evaluate_affine(blocks, theta, bias, degree: int | None = None) -> np.ndarray: + """Evaluate sum_{k=0}^degree B_k theta_k + bias.""" + blocks = finite_array(blocks, "blocks", 3) + theta = finite_array(theta, "theta", 3) + bias = finite_array(bias, "bias", 1) + degree = validate_degree(blocks.shape[0] - 1 if degree is None else degree) + if degree >= blocks.shape[0] or degree >= theta.shape[0]: + raise ValueError("selected degree exceeds the available blocks or theta") + if theta.shape[1] != blocks.shape[2] or theta.shape[2] != bias.size: + raise ValueError("theta feature/hidden dimensions must match blocks and bias") + with np.errstate(over="ignore", invalid="ignore"): + value = np.einsum("knf,kfh->nh", blocks[:degree + 1], theta[:degree + 1]) + bias + if not np.isfinite(value).all(): + raise ValueError("affine evaluation overflowed") + return value + + +def evaluate_polynomial(value, coefficients) -> np.ndarray: + """Evaluate a polynomial with coefficients in ascending power order.""" + value = finite_array(value, "activation input", 2) + coefficients = finite_array(coefficients, "activation coefficients", 1) + if coefficients.size > 17: + raise ValueError("activation degree must be at most 16") + result = np.zeros_like(value) + with np.errstate(over="ignore", invalid="ignore"): + for coefficient in coefficients[::-1]: + result = result * value + coefficient + if not np.isfinite(result).all(): + raise ValueError("polynomial evaluation overflowed") + return result + + +def evaluate_readout(hidden, output_weights, output_bias) -> np.ndarray: + hidden = finite_array(hidden, "hidden", 2) + weights = finite_array(output_weights, "output weights", 2) + bias = finite_array(output_bias, "output bias", 1) + if hidden.shape[1] != weights.shape[0] or weights.shape[1] != bias.size: + raise ValueError("readout dimensions must match hidden and bias") + with np.errstate(over="ignore", invalid="ignore"): + result = hidden @ weights + bias + if not np.isfinite(result).all(): + raise ValueError("readout evaluation overflowed") + return result + + +def model_arrays(model: Mapping) -> dict[str, np.ndarray]: + """Validate the clear model, shared by plaintext and CKKS evaluators.""" + if not isinstance(model, Mapping): + raise ValueError("model must be a mapping") + dimensions = {"theta": 3, "bias": 1, "activation_coefficients": 1, + "output_weights": 2, "output_bias": 1} + try: + arrays = {key: finite_array(model[key], key, ndim) for key, ndim in dimensions.items()} + except KeyError as exc: + raise ValueError(f"model is missing {exc.args[0]}") from exc + theta, bias = arrays["theta"], arrays["bias"] + weights, output_bias = arrays["output_weights"], arrays["output_bias"] + if theta.shape[0] > MAX_DEGREE + 1 or theta.shape[2] != bias.size: + raise ValueError("model theta degree/hidden dimensions are invalid") + if weights.shape != (bias.size, output_bias.size): + raise ValueError("model readout dimensions do not match") + return arrays + + +def evaluate_model(blocks, model: Mapping, degree: int | None = None) -> np.ndarray: + model = model_arrays(model) + affine = evaluate_affine(blocks, model["theta"], model["bias"], degree) + hidden = evaluate_polynomial(affine, model["activation_coefficients"]) + return evaluate_readout(hidden, model["output_weights"], model["output_bias"]) diff --git a/applications/krylov/demo.py b/applications/krylov/demo.py new file mode 100644 index 0000000..942437e --- /dev/null +++ b/applications/krylov/demo.py @@ -0,0 +1,138 @@ +"""Small deterministic, independently trained illustrative graph classifier.""" + +from collections.abc import Mapping + +import numpy as np + +from .core import MAX_DEGREE, compile_chebyshev_blocks, evaluate_affine, normalize_adjacency + +SOURCE_URL = "https://github.com/chimans/PrivNet-Krylov" +SOURCE_COMMIT = "0976e41881192da938ef2ab988c2fe3fdb92835b" +DEFAULT_DEGREE = 2 + + +def _ridge(design, target, penalties): + # Solve a regularized least-squares system, including a weakly penalized bias. + augmented = np.column_stack([design, np.ones(design.shape[0])]) + regularization = np.diag(np.r_[penalties, 1e-6]) + solution = np.linalg.solve(augmented.T @ augmented + regularization, augmented.T @ target) + return solution[:-1], solution[-1] + + +def make_demo(seed: int = 17) -> dict: + """Return a public 24-node fixture; all labels are used during fitting. + + Accuracy on this fixture is illustrative training/all-node accuracy. It is + deliberately not a held-out research result or an upstream benchmark. + """ + rng = np.random.default_rng(seed) + node_count, feature_count, hidden_count = 24, 3, 4 + labels = np.repeat([0, 1], node_count // 2) + edge_set = set() + for start in (0, node_count // 2): + for i in range(node_count // 2): + for offset in (1, 2): + left = start + i + right = start + (i + offset) % (node_count // 2) + edge_set.add(tuple(sorted((left, right)))) + edge_set.update({(2, 14), (7, 19), (10, 22)}) + edges = sorted(edge_set) + adjacency = np.zeros((node_count, node_count)) + for left, right in edges: + adjacency[left, right] = adjacency[right, left] = 1.0 + signed_class = 2 * labels - 1 + features = np.column_stack([ + 0.5 * signed_class + rng.normal(0, 0.55, node_count), + 0.2 * signed_class + rng.normal(0, 0.35, node_count), + np.full(node_count, 0.25), + ]) + blocks = compile_chebyshev_blocks(normalize_adjacency(adjacency), features, MAX_DEGREE) + design = blocks.transpose(1, 0, 2).reshape(node_count, -1) + one_hot = np.eye(2)[labels] + hidden_targets = one_hot @ np.array([[0.9, 0.2, 0.65, 0.1], [0.2, 0.9, 0.1, 0.65]]) + penalties = np.repeat(0.15 * (1 + 0.7 * np.arange(MAX_DEGREE + 1) ** 2), feature_count) + flat_theta, bias = _ridge(design, hidden_targets, penalties) + theta = flat_theta.reshape(MAX_DEGREE + 1, feature_count, hidden_count) + hidden = evaluate_affine(blocks, theta, bias, MAX_DEGREE) ** 2 + output_weights, output_bias = _ridge(hidden, one_hot, np.full(hidden_count, 0.02)) + model = { + "theta": theta.tolist(), "bias": bias.tolist(), + "activation_coefficients": [0.0, 0.0, 1.0], + "output_weights": output_weights.tolist(), "output_bias": output_bias.tolist(), + } + return { + "schema_version": 1, + "name": "Synthetic two-community Krylov demonstration", + "nodes": [{"id": i, "label": f"Node {i + 1}"} for i in range(node_count)], + "edges": [list(edge) for edge in edges], + "features": features.tolist(), "labels": labels.tolist(), "model": model, + "default_degree": DEFAULT_DEGREE, + "provenance": { + "implementation": "Independent NumPy implementation; no upstream code copied", + "mathematical_source_url": SOURCE_URL, "source_commit": SOURCE_COMMIT, + "dataset": "Public synthetic two-community graph generated locally", + "seed": seed, "training": "Two ridge fits with square activation; all nodes used", + "training_degree": MAX_DEGREE, "maximum_degree": MAX_DEGREE, + "evaluation_scope": "Illustrative all-node/training accuracy; no held-out split", + "normalization": "S = D^-1/2 (A + I) D^-1/2; undirected unweighted edges", + "degree_selection": "Truncates a fixed trained theta bank; does not retrain", + "privacy": "Browser artifact is entirely public; browser arithmetic is plaintext", + "benchmark_claim": "No upstream accuracy, runtime, security or PQ claim is reproduced", + }, + } + + +def fixture_arrays(fixture: dict): + """Validate demo graph semantics and return adjacency, features, labels.""" + if not isinstance(fixture, Mapping): + raise ValueError("demo fixture must be a JSON object") + if type(fixture.get("schema_version")) is not int or fixture.get("schema_version") != 1: + raise ValueError("unsupported demo schema version") + if not isinstance(fixture.get("provenance"), Mapping): + raise ValueError("demo provenance must be an object") + nodes = fixture.get("nodes", []) + if not isinstance(nodes, (list, tuple)) or not 1 <= len(nodes) <= 120: + raise ValueError("demo fixture must contain from 1 through 120 nodes") + if any(not isinstance(node, Mapping) for node in nodes): + raise ValueError("each demo node must be an object") + if any(isinstance(node.get("id"), bool) or not isinstance(node.get("id"), int) for node in nodes): + raise ValueError("node ids must be integers") + if [node.get("id") for node in nodes] != list(range(len(nodes))): + raise ValueError("node ids must be consecutive integers starting at zero") + if any(not isinstance(node.get("label"), str) for node in nodes): + raise ValueError("node labels must be strings") + adjacency = np.zeros((len(nodes), len(nodes))) + seen = set() + edges = fixture.get("edges", []) + if not isinstance(edges, (list, tuple)) or len(edges) > len(nodes) * (len(nodes) - 1) // 2: + raise ValueError("edges must be a bounded list of undirected pairs") + for edge in edges: + if not isinstance(edge, (list, tuple)) or len(edge) != 2: + raise ValueError("each edge must have two integer endpoints") + left, right = edge + if any(isinstance(i, bool) or not isinstance(i, int) for i in edge): + raise ValueError("edge endpoints must be integers") + if not (0 <= left < right < len(nodes)) or (left, right) in seen: + raise ValueError("edges must be unique, ordered, non-self undirected pairs") + seen.add((left, right)) + adjacency[left, right] = adjacency[right, left] = 1 + from .core import finite_array, model_arrays, validate_degree + + features = finite_array(fixture.get("features"), "features", 2) + labels_raw = np.asarray(fixture.get("labels")) + if labels_raw.ndim != 1 or labels_raw.dtype.kind not in "iu": + raise ValueError("labels must be an integer vector") + labels = labels_raw.astype(np.int64) + model = model_arrays(fixture.get("model")) + if features.shape[1] > 32 or model["bias"].size > 32 or model["output_bias"].size > 16: + raise ValueError("demo dimensions exceed F/H <= 32 and C <= 16") + if features.shape[0] != len(nodes) or labels.size != len(nodes): + raise ValueError("one feature row and label are required per node") + if model["theta"].shape[1] != features.shape[1]: + raise ValueError("model feature width must match fixture features") + if np.any(labels < 0) or np.any(labels >= model["output_bias"].size): + raise ValueError("labels must be valid class indices") + default_degree = validate_degree(fixture.get("default_degree")) + if default_degree >= model["theta"].shape[0]: + raise ValueError("default degree exceeds the exported theta bank") + return adjacency, features, labels diff --git a/docs/krylov.md b/docs/krylov.md new file mode 100644 index 0000000..33bf563 --- /dev/null +++ b/docs/krylov.md @@ -0,0 +1,97 @@ +# Krylov application and interactive lab + +PrivnetAI includes an independently implemented application of the client-side compilation method described in [PrivNet-Krylov](https://github.com/chimans/PrivNet-Krylov), by **Ehsan Hakimian and Chiman Soltanian**. The reviewed upstream revision is [`0976e41881192da938ef2ab988c2fe3fdb92835b`](https://github.com/chimans/PrivNet-Krylov/tree/0976e41881192da938ef2ab988c2fe3fdb92835b). + +The upstream repository had no license file when reviewed. This integration contains original Python/JavaScript code and a newly generated synthetic model; it does not vendor upstream code, model artifacts, figures or benchmark results. It implements a separate shallow spectral architecture alongside the existing two-layer GCN. + +## Computation + +For an undirected graph, the client computes `S = D^-1/2 (A + I) D^-1/2` and the Chebyshev feature bank: + +```text +B0 = X +B1 = S X +Bk = 2 S B(k-1) - B(k-2) +U = sum_k Bk theta_k + bias +Z = UΒ² output_weights + output_bias +``` + +Every block is encrypted in the CKKS mode, including `B0`. The evaluator receives encrypted columns and the clear model, evaluates the affine/square/readout circuit, and returns encrypted class logits. It receives no adjacency or plaintext bank. The compiled evaluator uses no graph-dependent rotations. Increasing degree expands the upload by adding feature columns; it does not add graph recurrence to the evaluator. + +```mermaid +flowchart LR + G[Client graph and features] --> B[Client Chebyshev bank] + B --> E[Encrypt every bank column] + E --> S[Evaluator: affine, square, readout] + S --> D[Client decrypts logits] + P[Serialized public context and clear model] --> S +``` + +Compilation alone provides no confidentiality: plaintext blocks can reveal graph structure. The sample graph, model and labels are public. CKKS protects the encrypted payload under its assumptions; node/feature counts, degree, model and serialized sizes remain public. Both demo roles execute locally in one process/browser, with a serialized key boundary. This is not a deployed client/server service or process isolation. + +## Python application + +From the repository root, the plaintext path requires NumPy and does not download a dataset: + +```bash +python -m applications.krylov --backend plain --degree 2 --output /tmp/krylov-plain.json +``` + +Install the optional backend and execute real encryption: + +```bash +pip install -r requirements-ckks.txt +python -m applications.krylov --backend ckks --degree 2 --output /tmp/krylov-ckks.json +``` + +The CKKS backend uses TenSEAL, serializes a public/evaluation context without the secret key, reconstructs the evaluator's ciphertexts from bytes, then decrypts only with the client context. Missing TenSEAL or unsupported circuits produce an error rather than a plaintext fallback. The result records parameters, environment, payload sizes, stage timings, errors and same-model prediction agreement. Only the square activation is supported in this encrypted demonstration. + +The library exposes `normalize_adjacency`, `compile_chebyshev_blocks` and `evaluate_model` from `applications.krylov`. `applications.krylov.ckks` exposes `client_encrypt_blocks`, `evaluate_server` and `run_ckks`. The evaluator's request type has no graph, labels, plaintext features or secret-key field. + +Use `--fixture path/to/fixture.json` to evaluate an existing browser-schema fixture. It defaults to that fixture's `default_degree` unless `--degree` is supplied. Input, result and fixture-export paths must be distinct; the CLI caps fixture files at 1 MiB and validates graph/model dimensions before allocating the graph shift. + +## Browser lab + +The GitHub Pages application runs without a hosted API. Visitors can change degree and feature perturbation, inspect the graph and per-node predictions, compare the compiled result with an independent direct polynomial evaluation, import a local JSON fixture, and export diagnostics. Imported graph data stays in the browser. The default mode is explicitly **local plaintext**. + +The separate **real CKKS** action loads node-seal WebAssembly on demand and encrypts the bundled demo's compiled columns. A fresh evaluator context is reconstructed from serialized public parameters/keys and ciphertexts. The secret key stays with the local client. The page reports actual encryption/evaluation/decryption measurements and errors against the identical plaintext model. It does not upload the bank or secret key to a remote evaluator. Encrypted execution is limited to the bundled fixture; custom fixtures use the plaintext lab. + +For development, use Node 24 and the committed lockfile: + +```bash +npm ci +npm run dev +``` + +For a production build and validation: + +```bash +npm test +npm run build +npx playwright install chromium +npm run test:browser +``` + +The Vite build uses relative asset paths, so it works under the GitHub Pages project path `/PrivnetAI/`. All runtime code and the WASM binary are packaged with the site; an external CDN is not required. The first encrypted run downloads the WASM asset and creates fresh keys, so it is slower than plaintext execution and can use substantial memory. + +## Fixture and measurement scope + +The bundled JSON at [site/data/demo.json](../site/data/demo.json) describes 24 synthetic nodes in two communities, 3 features, a degree-4 parameter bank, 4 hidden channels and 2 classes. Two local ridge fits train the square model using **all nodes**. The default degree is 2. Changing degree truncates the fixed parameter bank; it does not retrain the model. + +Displayed accuracy is illustrative all-node/training accuracy, not a held-out generalization result. The demo does not reproduce upstream research benchmarks, claims of speedup, or post-quantum security. Plaintext and CKKS use the same selected degree, features, parameters and activation. + +Regenerate the public fixture with: + +```bash +python -m applications.krylov --export-demo site/data/demo.json --output /tmp/krylov-plain.json +``` + +Custom JSON uses the same schema: `schema_version`, `nodes`, unique undirected `edges`, `features`, integer `labels`, `default_degree`, and `model` containing `theta`, `bias`, ascending `activation_coefficients`, `output_weights` and `output_bias`. Node IDs are consecutive integers starting at zero. Shapes must agree. The browser limits graph/model size and rejects invalid, nonfinite or excessively large files. + +## Security and publication boundaries + +The demo CKKS tuple is polynomial modulus degree 16,384, coefficient-modulus bits `[60, 40, 40, 40, 40, 60]`, scale `2^40`. It passes SEAL's default classical parameter validation. This is not an independent concrete-security estimate, a post-quantum security claim, malicious-server integrity, or an output-oracle defense. The bundled backend releases are for experimental demonstration; publication-quality security assessment and a hardened network protocol require separate work. + +The new Krylov/browser checks are in `.github/workflows/krylov.yml`. The legacy simulator precision defect documented in the [research archive](research/2026-10-10/README.md) remains independent of this application. + +The initial tested site is published from a generated `gh-pages` branch at [aliaslany.github.io/PrivnetAI](https://aliaslany.github.io/PrivnetAI/). For subsequent updates, `.github/workflows/pages.yml` builds and tests the site on `main`, uploads a Pages artifact and deploys it using GitHub's supported Pages actions. It also supports manual dispatch. Source changes are reviewed through a feature PR; the initial demo can be tested before that PR is merged. Pages settings must select GitHub Actions for this ongoing deployment workflow. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..069c07a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,853 @@ +{ + "name": "privnetai-krylov-lab", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "privnetai-krylov-lab", + "version": "0.1.0", + "dependencies": { + "node-seal": "7.0.0" + }, + "devDependencies": { + "@playwright/test": "1.64.0", + "vite": "8.3.4" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.153.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz", + "integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@playwright/test": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz", + "integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.64.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz", + "integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz", + "integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz", + "integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz", + "integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz", + "integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz", + "integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz", + "integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz", + "integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz", + "integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz", + "integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz", + "integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz", + "integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz", + "integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz", + "integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz", + "integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-seal": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/node-seal/-/node-seal-7.0.0.tgz", + "integrity": "sha512-R5q8PQ6z3Hyq7x1uunNf5yZtDuSlffYkWFA5uvzL2fYF/IHL7+KwCsRdLlG3inBU1dMUgakDWiuqf0csMvcang==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz", + "integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz", + "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postcss": { + "version": "8.5.29", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz", + "integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.19", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz", + "integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.153.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.13", + "@rolldown/binding-android-arm64": "1.2.13", + "@rolldown/binding-darwin-arm64": "1.2.13", + "@rolldown/binding-darwin-x64": "1.2.13", + "@rolldown/binding-freebsd-x64": "1.2.13", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.13", + "@rolldown/binding-linux-arm64-gnu": "1.2.13", + "@rolldown/binding-linux-arm64-musl": "1.2.13", + "@rolldown/binding-linux-ppc64-gnu": "1.2.13", + "@rolldown/binding-linux-s390x-gnu": "1.2.13", + "@rolldown/binding-linux-x64-gnu": "1.2.13", + "@rolldown/binding-linux-x64-musl": "1.2.13", + "@rolldown/binding-openharmony-arm64": "1.2.13", + "@rolldown/binding-win32-arm64-msvc": "1.2.13", + "@rolldown/binding-win32-x64-msvc": "1.2.13" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/vite": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..82cad88 --- /dev/null +++ b/package.json @@ -0,0 +1,20 @@ +{ + "name": "privnetai-krylov-lab", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1 --port 4173", + "build": "vite build", + "preview": "vite preview --host 127.0.0.1 --port 4173", + "test": "node --test site/*.test.mjs", + "test:browser": "playwright test" + }, + "dependencies": { + "node-seal": "7.0.0" + }, + "devDependencies": { + "@playwright/test": "1.64.0", + "vite": "8.3.4" + } +} diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..05ef288 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,20 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/browser', + timeout: 90_000, + expect: { timeout: 15_000 }, + workers: 1, + forbidOnly: Boolean(process.env.CI), + use: { + baseURL: 'http://127.0.0.1:4173/PrivnetAI/', + headless: true, + trace: 'retain-on-failure', + }, + webServer: { + command: 'npm run preview -- --base /PrivnetAI/', + url: 'http://127.0.0.1:4173/PrivnetAI/', + reuseExistingServer: false, + timeout: 30_000, + }, +}); diff --git a/requirements-ckks.txt b/requirements-ckks.txt new file mode 100644 index 0000000..ae510a1 --- /dev/null +++ b/requirements-ckks.txt @@ -0,0 +1,2 @@ +# Optional genuine CKKS backend; NumPy is supplied by requirements.txt. +tenseal==0.3.16 diff --git a/site/app.js b/site/app.js new file mode 100644 index 0000000..af5e1ae --- /dev/null +++ b/site/app.js @@ -0,0 +1,349 @@ +import { validateFixture, runExperiment, perturbFeatures, LIMITS } from './krylov.js'; + +const fixtureUrl = new URL('./data/demo.json', import.meta.url); +const $ = (id) => document.getElementById(id); +const SVG = 'http://www.w3.org/2000/svg'; +const state = { fixture: null, bundled: null, result: null, selected: 0, predictions: true, custom: false, running: false, encryptedRunning: false, encryptedResult: null, version: 0 }; +const palette = ['#749b78', '#cc9068', '#7e9eac', '#ab91af', '#b3ab6a', '#7fa8a0']; +const color = (classIndex) => palette[classIndex % palette.length]; +const number = (value) => Number.isFinite(value) ? value.toLocaleString(undefined, { maximumFractionDigits: 3 }) : 'β€”'; +const scientific = (value) => value === 0 ? '0' : value.toExponential(2); +const percent = (value) => (value * 100).toFixed(1); +const timing = (value) => `${value.toFixed(3)} ms`; +const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve)); + +function element(tag, text, className) { + const result = document.createElement(tag); + if (text !== undefined) result.textContent = text; + if (className) result.className = className; + return result; +} + +function svgElement(tag, attributes) { + const result = document.createElementNS(SVG, tag); + for (const [key, value] of Object.entries(attributes)) result.setAttribute(key, String(value)); + return result; +} + +function setError(message = '') { + $('page-error').textContent = message; + $('page-error').hidden = !message; +} + +function setControlsEnabled() { + const busy = state.running || state.encryptedRunning; + for (const id of ['run', 'reset', 'degree', 'perturbation', 'upload', 'upload-button', 'restore-demo']) $(id).disabled = busy || !state.fixture; + $('run-ckks').disabled = busy || !state.fixture || state.custom; + $('export').disabled = busy || !state.result; +} + +function updateControlLabels(dirty = false) { + $('degree-value').textContent = `K = ${$('degree').value}`; + $('perturbation-value').textContent = `Β±${Number($('perturbation').value).toFixed(2)}`; + for (const id of ['degree', 'perturbation']) { + const range = $(id); + const fraction = (Number(range.value) - Number(range.min)) / (Number(range.max) - Number(range.min) || 1); + range.style.background = `linear-gradient(to right, #59856c ${fraction * 100}%, #e6ece2 ${fraction * 100}%)`; + } + if (dirty) { + state.encryptedResult = null; + $('ckks-results').hidden = true; + if (!state.custom) $('ckks-status').textContent = 'Controls changed. Run CKKS again for the current settings.'; + if (state.result) $('run-status').textContent = 'Controls changed. Run to update the graph and diagnostics.'; + } +} + +function configureFixture(fixture, custom) { + state.fixture = fixture; + state.custom = custom; + state.result = null; + state.selected = 0; + state.encryptedResult = null; + state.version++; + $('ckks-results').hidden = true; + $('degree').max = Math.min(LIMITS.degree, fixture.model.theta.length - 1); + $('degree').value = fixture.default_degree; + $('degree-scale').replaceChildren(...Array.from({ length: Number($('degree').max) + 1 }, (_, k) => element('span', k))); + $('perturbation').value = 0; + $('fixture-name').textContent = fixture.name; + $('graph-shape').textContent = `${fixture.nodes.length} nodes Β· ${fixture.edges.length} edges Β· ${fixture.features[0].length} features`; + $('fixture-kind').textContent = custom ? 'LOCAL JSON' : 'BUNDLED DEMO'; + $('restore-demo').hidden = !custom; + $('ckks-status').textContent = custom ? 'Real CKKS is limited to the bundled example. Local plaintext remains available.' : 'Loads the cryptographic library only when you run it.'; + $('legend').replaceChildren(...fixture.model.output_bias.map((_, c) => { + const item = element('span', undefined, 'legend-item'); + const dot = element('span', undefined, 'legend-dot'); + dot.style.background = color(c); + item.append(dot, element('span', `Class ${c}`)); + return item; + })); + updateControlLabels(); + setControlsEnabled(); + for (const id of ['agreement', 'accuracy', 'max-error', 'mean-error', 'bank-count', 'compile-time', 'inference-time', 'oracle-time']) $(id).textContent = 'β€”'; + $('node-id').textContent = 'β€”'; + $('node-summary').replaceChildren(); + $('logit-bars').replaceChildren(); + $('bank-tiles').replaceChildren(); + renderGraph(); +} + +function positions(fixture) { + const classCount = fixture.model.output_bias.length; + const counts = Array(classCount).fill(0); + fixture.labels.forEach((label) => counts[label]++); + const seen = Array(classCount).fill(0); + return fixture.nodes.map((_, n) => { + const c = fixture.labels[n]; + const i = seen[c]++; + let cx = 410; let cy = 180; + if (classCount > 1) { + const angle = -Math.PI / 2 + c * 2 * Math.PI / classCount; + cx += Math.cos(angle) * (classCount === 2 ? 215 : 220); + cy += Math.sin(angle) * (classCount === 2 ? 95 : 88); + } + const angle = i * 2.399963229728653 + c * 0.42; + const radius = Math.sqrt((i + 0.7) / Math.max(counts[c], 1)) * Math.min(classCount > 4 ? 46 : 89, 29 + counts[c] * 4); + return { x: Math.max(35, Math.min(785, cx + Math.cos(angle) * radius)), y: Math.max(35, Math.min(325, cy + Math.sin(angle) * radius * 0.76)) }; + }); +} + +function renderGraph() { + const fixture = state.fixture; + const graph = $('graph'); + const points = positions(fixture); + const title = svgElement('title', { id: 'graph-accessible-title' }); + title.textContent = `${fixture.name}: ${fixture.nodes.length} nodes`; + const description = svgElement('desc', { id: 'graph-description' }); + description.textContent = `Node colors show ${state.predictions ? 'predicted classes' : 'reference labels'}. Select a node to inspect raw logits. Layout is illustrative.`; + const edges = svgElement('g', { 'aria-hidden': 'true' }); + fixture.edges.forEach(([a, b]) => edges.append(svgElement('line', { + x1: points[a].x, y1: points[a].y, x2: points[b].x, y2: points[b].y, + class: `graph-edge${a === state.selected || b === state.selected ? ' highlight' : ''}`, + }))); + const nodes = svgElement('g', {}); + const radius = fixture.nodes.length > 70 ? 7 : fixture.nodes.length > 36 ? 9 : 12; + fixture.nodes.forEach((node, n) => { + const prediction = state.result?.compiled.predictions[n]; + const classIndex = state.predictions && prediction !== undefined ? prediction : fixture.labels[n]; + const group = svgElement('g', { transform: `translate(${points[n].x},${points[n].y})`, class: `graph-node${n === state.selected ? ' selected' : ''}`, + tabindex: 0, role: 'button', 'aria-label': `${node.label}, reference class ${fixture.labels[n]}${prediction === undefined ? '' : `, predicted class ${prediction}`}. Inspect scores.` }); + const tooltip = svgElement('title', {}); + tooltip.textContent = `${node.label} Β· ${node.id}`; + group.append(tooltip, svgElement('circle', { r: radius + 7, class: 'selection-ring' }), svgElement('circle', { r: radius, fill: color(classIndex) })); + if (fixture.nodes.length <= 70) { + const text = svgElement('text', { x: 0, y: 0 }); + text.textContent = n + 1; + group.append(text); + } + const select = () => { state.selected = n; renderGraph(); renderNode(); }; + group.addEventListener('click', select); + group.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); select(); } + }); + nodes.append(group); + }); + graph.replaceChildren(title, description, edges, nodes); +} + +function renderNode() { + if (!state.result) return; + const n = state.selected; + const fixture = state.fixture; + const logits = state.result.compiled.logits[n]; + const prediction = state.result.compiled.predictions[n]; + $('node-id').textContent = `NODE ${fixture.nodes[n].id}`; + $('node-summary').replaceChildren(element('strong', fixture.nodes[n].label), element('span', `Prediction ${prediction}`, 'class-pill'), element('span', `Reference ${fixture.labels[n]}`)); + const lower = Math.min(0, ...logits); + const upper = Math.max(0, ...logits); + const span = upper - lower || 1; + const zero = -lower / span * 100; + $('logit-bars').replaceChildren(...logits.map((logit, c) => { + const row = element('div', undefined, 'logit-row'); + const track = element('div', undefined, 'logit-track'); + track.setAttribute('aria-hidden', 'true'); + const marker = element('span', undefined, 'logit-zero'); + marker.style.left = `${zero}%`; + const fill = element('span', undefined, 'logit-fill'); + const endpoint = (logit - lower) / span * 100; + fill.style.left = `${Math.min(zero, endpoint)}%`; + fill.style.width = `${Math.abs(zero - endpoint)}%`; + fill.style.background = color(c); + track.append(marker, fill); + row.append(element('span', `Class ${c}`), track, element('span', logit.toFixed(4), 'logit-value')); + return row; + })); +} + +function renderResults() { + const { diagnostics, timing_ms: times, degree, graph, bank_scalar_count: scalars } = state.result; + $('agreement').replaceChildren(document.createTextNode(percent(diagnostics.prediction_agreement)), element('small', '%')); + $('accuracy').replaceChildren(document.createTextNode(percent(diagnostics.illustrative_all_node_accuracy)), element('small', '%')); + $('max-error').textContent = scientific(diagnostics.max_absolute_logit_error); + $('mean-error').textContent = scientific(diagnostics.mean_absolute_logit_error); + $('bank-count').textContent = number(scalars); + $('bank-formula').textContent = `${degree + 1} blocks Γ— ${graph.nodes} nodes Γ— ${graph.features} features`; + $('compile-time').textContent = timing(times.compile); + $('inference-time').textContent = timing(times.compiled_inference); + $('oracle-time').textContent = timing(times.direct_oracle); + $('bank-tiles').replaceChildren(...Array.from({ length: degree + 1 }, (_, k) => element('span', `B${'₀₁₂₃₄'[k]}`, 'bank-tile'))); + $('bank-description').textContent = `${degree + 1} block${degree ? 's' : ''} Β· ${scalars.toLocaleString()} scalar values`; + renderGraph(); + renderNode(); +} + +async function run() { + if (!state.fixture || state.running || state.encryptedRunning) return; + state.running = true; + state.encryptedResult = null; + $('ckks-results').hidden = true; + setError(); + setControlsEnabled(); + $('run-status').textContent = 'Compiling features and checking the direct oracle…'; + try { + await nextFrame(); + await nextFrame(); + state.result = runExperiment(state.fixture, Number($('degree').value), Number($('perturbation').value)); + state.encryptedResult = null; + $('ckks-results').hidden = true; + renderResults(); + $('run-status').textContent = `Complete Β· K = ${state.result.degree} Β· noise Β±${state.result.perturbation.toFixed(2)} Β· local plaintext.`; + } catch (error) { + setError(error.message || 'The experiment could not be evaluated.'); + $('run-status').textContent = 'Run failed. Adjust the input and try again.'; + } finally { + state.running = false; + setControlsEnabled(); + } +} + +function downloadJson(data, name) { + const blob = new Blob([JSON.stringify(data, null, 2) + '\n'], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const link = element('a'); + link.href = url; + link.download = name; + document.body.append(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +function renderEncrypted(result) { + $('ckks-results').hidden = false; + const stats = [ + ['Same-model agreement', `${percent(result.predictionAgreement)}%`], + ['Maximum logit error', scientific(result.maxAbsoluteError)], + ['Ciphertext bytes', number(result.ciphertextBytes)], + ['Evaluator secret key', result.publicContextHasSecretKey === false ? 'Absent from context' : 'Check module result'], + ]; + $('ckks-diagnostics').replaceChildren(...stats.map(([label, value]) => { + const cell = element('div', undefined, 'ckks-stat'); + cell.append(element('span', label), element('strong', value)); + return cell; + })); + const detail = element('div', undefined, 'ckks-stat'); + detail.style.gridColumn = '1 / -1'; + detail.append(element('span', 'Parameters and local timings'), element('strong', JSON.stringify({ params: result.params, timings: result.timings }))); + detail.style.overflowWrap = 'anywhere'; + $('ckks-diagnostics').append(detail); + $('ckks-scope').textContent = typeof result.scope === 'string' ? result.scope : 'Genuine CKKS evaluation of the locally compiled feature bank.'; +} + +async function runEncrypted() { + if (!state.fixture || state.custom || state.running || state.encryptedRunning) return; + const fixture = state.fixture; + state.encryptedRunning = true; + setControlsEnabled(); + setError(); + $('ckks-status').textContent = 'Loading the CKKS WebAssembly module…'; + try { + const degree = Number($('degree').value); + const perturbation = Number($('perturbation').value); + state.encryptedResult = null; + $('ckks-results').hidden = true; + state.result = runExperiment(fixture, degree, perturbation); + renderResults(); + $('run-status').textContent = `Plaintext checked Β· K = ${degree} Β· noise Β±${perturbation.toFixed(2)}.`; + const module = await import('./ckks.js'); + const features = perturbFeatures(fixture.features, perturbation); + const result = await module.runEncrypted(fixture, { degree, features, onProgress: (message) => { $('ckks-status').textContent = String(message); } }); + state.encryptedResult = { ...result, degree, perturbation }; + renderEncrypted(result); + $('ckks-status').textContent = `Complete Β· genuine local CKKS readout Β· K = ${degree}.`; + } catch (error) { + $('ckks-status').textContent = 'Encrypted run unavailable. The plaintext experiment remains usable.'; + setError(`CKKS prototype: ${error.message || 'Unable to load or execute the cryptographic module.'}`); + } finally { + state.encryptedRunning = false; + setControlsEnabled(); + } +} + +$('experiment-form').addEventListener('submit', (event) => { event.preventDefault(); run(); }); +for (const id of ['degree', 'perturbation']) $(id).addEventListener('input', () => updateControlLabels(true)); +$('reset').addEventListener('click', () => { + $('degree').value = state.fixture.default_degree; + $('perturbation').value = 0; + updateControlLabels(); + run(); +}); +$('show-predictions').addEventListener('click', () => { + state.predictions = true; + $('show-predictions').classList.add('active'); + $('show-labels').classList.remove('active'); + $('show-predictions').setAttribute('aria-pressed', 'true'); + $('show-labels').setAttribute('aria-pressed', 'false'); + if (state.fixture) renderGraph(); +}); +$('show-labels').addEventListener('click', () => { + state.predictions = false; + $('show-labels').classList.add('active'); + $('show-predictions').classList.remove('active'); + $('show-labels').setAttribute('aria-pressed', 'true'); + $('show-predictions').setAttribute('aria-pressed', 'false'); + if (state.fixture) renderGraph(); +}); +$('upload-button').addEventListener('click', () => $('upload').click()); +$('upload').addEventListener('change', async () => { + const file = $('upload').files[0]; + if (!file) return; + const version = state.version; + try { + if (file.size > 256 * 1024) throw new Error('The JSON exceeds the 256 KB demo limit. Choose a smaller graph.'); + const fixture = validateFixture(JSON.parse(await file.text())); + if (state.running || state.encryptedRunning || state.version !== version) throw new Error('Another experiment started while the file was opening. Upload it again when that run finishes.'); + configureFixture(fixture, true); + await run(); + } catch (error) { + const message = error instanceof SyntaxError ? 'The file is not valid JSON.' : error.message; + setError(`Local upload: ${message} Your current graph is unchanged.`); + } finally { + $('upload').value = ''; + } +}); +$('restore-demo').addEventListener('click', () => { configureFixture(state.bundled, false); run(); }); +$('download-fixture').addEventListener('click', (event) => { event.preventDefault(); if (state.bundled) downloadJson(state.bundled, 'privnetai-demo.json'); }); +$('export').addEventListener('click', () => { + if (!state.result) return; + downloadJson({ ...state.result, exported_at_utc: new Date().toISOString(), optional_ckks: state.encryptedResult }, 'privnetai-local-result.json'); +}); +$('run-ckks').addEventListener('click', runEncrypted); + +async function initialize() { + setControlsEnabled(); + try { + const response = await fetch(fixtureUrl); + if (!response.ok) throw new Error(`Fixture request returned HTTP ${response.status}.`); + state.bundled = validateFixture(await response.json()); + configureFixture(state.bundled, false); + await run(); + } catch (error) { + $('run-status').textContent = 'The bundled example could not be loaded.'; + setError(error.message || 'Unable to load the default graph.'); + // A local upload remains available even if the bundled asset is unavailable. + $('upload-button').disabled = false; + $('upload').disabled = false; + } +} + +initialize(); diff --git a/site/ckks.js b/site/ckks.js new file mode 100644 index 0000000..0afd5f0 --- /dev/null +++ b/site/ckks.js @@ -0,0 +1,322 @@ +import SEAL from 'node-seal'; +import { compileBank, evaluateCompiled } from './krylov.js'; + +// This is a local experimental demonstration. The evaluator below receives a +// serialized public request, never a secret key or an unencrypted Krylov bank. +// Both roles share one browser process; this is not process isolation. +const POLY_MODULUS_DEGREE = 16384; +const COEFF_MODULUS_BITS = [60, 40, 40, 40, 40, 60]; +const SCALE = 2 ** 40; +let runtimePromise; + +async function runtime() { + if (!runtimePromise) { + runtimePromise = (async () => { + if (typeof window === 'undefined') return SEAL(); + const { default: wasmUrl } = await import('node-seal-wasm?url'); + return SEAL({ + locateFile: (path) => (path.endsWith('.wasm') ? wasmUrl : path), + }); + })().catch((error) => { + runtimePromise = undefined; + throw error; + }); + } + return runtimePromise; +} + +const now = () => performance.now(); +const nextFrame = () => new Promise((resolve) => setTimeout(resolve, 0)); + +function resources() { + const owned = new Set(); + return { + keep(value) { + owned.add(value); + return value; + }, + drop(value) { + if (value && owned.delete(value)) value.delete(); + }, + dispose() { + for (const value of [...owned].reverse()) value.delete(); + owned.clear(); + }, + }; +} + +function validate(fixture, degree, features) { + const model = fixture?.model; + if (!Number.isInteger(degree) || degree < 0 || degree > 4) { + throw new Error('The local CKKS demo supports integer degrees from 0 to 4.'); + } + const nodes = features?.length; + const channels = features?.[0]?.length; + const hidden = model?.bias?.length; + const classes = model?.output_bias?.length; + if (!(nodes >= 1 && nodes <= 32 && channels >= 1 && channels <= 4 + && hidden >= 1 && hidden <= 8 && classes >= 2 && classes <= 8)) { + throw new Error('This CKKS demo requires a small graph and a compatible model.'); + } + if (fixture.nodes.length !== nodes + || features.some((row) => row.length !== channels) + || model.theta.length <= degree + || model.theta.slice(0, degree + 1).some((layer) => layer.length !== channels + || layer.some((row) => row.length !== hidden)) + || model.output_weights.length !== hidden + || model.output_weights.some((row) => row.length !== classes)) { + throw new Error('The graph features and model dimensions do not match.'); + } + if (JSON.stringify(model.activation_coefficients) !== '[0,0,1]') { + throw new Error('This encrypted circuit implements the square activation only.'); + } + const numbers = [features, model.theta, model.bias, + model.output_weights, model.output_bias].flat(Infinity); + if (numbers.some((value) => !Number.isFinite(value) || Math.abs(value) > 8)) { + throw new Error('Demo inputs and model coefficients must be finite and bounded by 8.'); + } + return { nodes, channels, hidden, classes }; +} + +function makeParameters(seal, owned) { + const parameters = owned.keep(new seal.EncryptionParameters(seal.SchemeType.ckks)); + parameters.setPolyModulusDegree(POLY_MODULUS_DEGREE); + const modulus = owned.keep(seal.CoeffModulus.Create( + POLY_MODULUS_DEGREE, Int32Array.from(COEFF_MODULUS_BITS), + )); + parameters.setCoeffModulus(modulus); + owned.drop(modulus); + return parameters; +} + +function publicContext(seal, request, owned) { + // Only these named fields cross the logical client/evaluator boundary. + const parameters = owned.keep(new seal.EncryptionParameters(seal.SchemeType.ckks)); + parameters.loadFromArray(request.parameters); + const context = owned.keep(new seal.SEALContext(parameters, true, seal.SecLevelType.tc128)); + if (!context.parametersSet()) throw new Error('SEAL rejected the encryption parameters.'); + const publicKey = owned.keep(new seal.PublicKey()); + publicKey.loadFromArray(context, request.publicKey); + const relinKeys = owned.keep(new seal.RelinKeys()); + relinKeys.loadFromArray(context, request.relinKeys); + return { context, publicKey, relinKeys }; +} + +function encodeScalar(seal, encoder, evaluator, owned, value, ciphertext) { + const plaintext = owned.keep(new seal.Plaintext()); + // A one-element vector does NOT broadcast in CKKS. Populate every slot. + const constant = new Float64Array(encoder.slotCount()); + constant.fill(value); + encoder.encode(constant, SCALE, plaintext); + const id = owned.keep(ciphertext.parmsId()); + evaluator.plainModSwitchToInplace(plaintext, id); + owned.drop(id); + return plaintext; +} + +async function evaluatePublic(seal, request, model, dimensions, progress) { + const owned = resources(); + try { + const { context, publicKey, relinKeys } = publicContext(seal, request, owned); + const encoder = owned.keep(new seal.CKKSEncoder(context)); + const evaluator = owned.keep(new seal.Evaluator(context)); + const encryptor = owned.keep(new seal.Encryptor(context, publicKey)); + const inputs = request.columns.map((degreeColumns) => degreeColumns.map((bytes) => { + const ciphertext = owned.keep(new seal.Ciphertext()); + ciphertext.loadFromArray(context, bytes); + return ciphertext; + })); + + function linearCombination(terms, bias) { + let result; + for (const [ciphertext, weight] of terms) { + if (weight === 0) continue; // Avoid transparent zero ciphertexts. + const plaintext = encodeScalar(seal, encoder, evaluator, owned, weight, ciphertext); + const product = owned.keep(new seal.Ciphertext()); + evaluator.multiplyPlain(ciphertext, plaintext, product); + owned.drop(plaintext); + if (result) { + evaluator.addInplace(result, product); + owned.drop(product); + } else result = product; + } + if (result) { + evaluator.rescaleToNextInplace(result); + result.setScale(SCALE); + } else { + // Encrypt a genuine zero with the public key; no client secret is used. + const zero = owned.keep(new seal.Plaintext()); + encoder.encode(new Float64Array(encoder.slotCount()), SCALE, zero); + result = owned.keep(new seal.Ciphertext()); + encryptor.encrypt(zero, result); + owned.drop(zero); + // Match the level reached by a nonzero linear combination. + const source = terms[0][0]; + const id = owned.keep(source.parmsId()); + evaluator.cipherModSwitchToInplace(result, id); + owned.drop(id); + evaluator.cipherModSwitchToNextInplace(result); + } + if (bias !== 0) { + const plaintext = encodeScalar(seal, encoder, evaluator, owned, bias, result); + evaluator.addPlainInplace(result, plaintext); + owned.drop(plaintext); + } + return result; + } + + const hidden = []; + for (let h = 0; h < dimensions.hidden; h += 1) { + await progress(`Evaluating encrypted hidden channel ${h + 1}/${dimensions.hidden}…`); + const terms = inputs.flatMap((columns, k) => columns.map((ciphertext, f) => ( + [ciphertext, model.theta[k][f][h]] + ))); + const activation = linearCombination(terms, model.bias[h]); + evaluator.squareInplace(activation); + evaluator.relinearizeInplace(activation, relinKeys); + evaluator.rescaleToNextInplace(activation); + activation.setScale(SCALE); + hidden.push(activation); + } + + const outputs = []; + for (let c = 0; c < dimensions.classes; c += 1) { + await progress(`Evaluating encrypted class ${c + 1}/${dimensions.classes}…`); + const result = linearCombination(hidden.map((ciphertext, h) => ( + [ciphertext, model.output_weights[h][c]] + )), model.output_bias[c]); + outputs.push(Uint8Array.from(result.saveToArray(seal.ComprModeType.none))); + owned.drop(result); + } + return outputs; + } finally { + owned.dispose(); + } +} + +/** Real CKKS evaluation of a client-compiled Krylov bank. Never falls back. */ +export async function runEncrypted(fixture, { + degree = fixture.default_degree ?? 2, + features = fixture.features, + onProgress = () => {}, +} = {}) { + const started = now(); + const timings = {}; + const dimensions = validate(fixture, degree, features); + const progress = async (message) => { + onProgress(message); + await nextFrame(); + }; + await progress('Loading the real CKKS WebAssembly runtime…'); + let mark = now(); + const seal = await runtime(); + timings.wasmLoadMs = now() - mark; + const owned = resources(); + try { + await progress('Compiling the Krylov bank on the client…'); + mark = now(); + const bank = compileBank(fixture, degree, features); + const reference = evaluateCompiled(fixture, bank, degree); + timings.compileMs = now() - mark; + + await progress('Creating fresh client keys and a public evaluator request…'); + mark = now(); + const parameters = makeParameters(seal, owned); + const context = owned.keep(new seal.SEALContext(parameters, true, seal.SecLevelType.tc128)); + if (!context.parametersSet()) throw new Error('SEAL rejected the encryption parameters.'); + const keyGenerator = owned.keep(new seal.KeyGenerator(context)); + const publicKey = owned.keep(keyGenerator.createPublicKey()); + const secretKey = owned.keep(keyGenerator.secretKey()); + const relinKeys = owned.keep(keyGenerator.createRelinKeys()); + const encoder = owned.keep(new seal.CKKSEncoder(context)); + const encryptor = owned.keep(new seal.Encryptor(context, publicKey)); + const decryptor = owned.keep(new seal.Decryptor(context, secretKey)); + const request = { + parameters: Uint8Array.from(parameters.saveToArray(seal.ComprModeType.none)), + publicKey: Uint8Array.from(publicKey.saveToArray(seal.ComprModeType.none)), + relinKeys: Uint8Array.from(relinKeys.saveToArray(seal.ComprModeType.none)), + columns: [], + }; + timings.keygenMs = now() - mark; + owned.drop(keyGenerator); + owned.drop(relinKeys); + + await progress('Encrypting each client Krylov-bank column…'); + mark = now(); + for (const layer of bank) { + const columns = []; + for (let f = 0; f < dimensions.channels; f += 1) { + const plaintext = owned.keep(new seal.Plaintext()); + encoder.encode(Float64Array.from(layer.map((row) => row[f])), SCALE, plaintext); + const ciphertext = owned.keep(new seal.Ciphertext()); + encryptor.encrypt(plaintext, ciphertext); + columns.push(Uint8Array.from(ciphertext.saveToArray(seal.ComprModeType.none))); + owned.drop(ciphertext); + owned.drop(plaintext); + } + request.columns.push(columns); + } + timings.encryptionMs = now() - mark; + const inputCiphertextBytes = request.columns.flat().reduce((sum, bytes) => sum + bytes.length, 0); + + await progress('Evaluating with public parameters and encrypted columns only…'); + mark = now(); + const outputs = await evaluatePublic(seal, request, fixture.model, dimensions, progress); + timings.evaluationMs = now() - mark; + const outputCiphertextBytes = outputs.reduce((sum, bytes) => sum + bytes.length, 0); + + await progress('Decrypting final logits locally and checking the plaintext reference…'); + mark = now(); + const logits = Array.from({ length: dimensions.nodes }, () => []); + for (const bytes of outputs) { + const ciphertext = owned.keep(new seal.Ciphertext()); + ciphertext.loadFromArray(context, bytes); + const plaintext = owned.keep(new seal.Plaintext()); + decryptor.decrypt(ciphertext, plaintext); + const values = encoder.decodeFloat64(plaintext); + for (let i = 0; i < dimensions.nodes; i += 1) logits[i].push(values[i]); + owned.drop(plaintext); + owned.drop(ciphertext); + } + timings.decryptionMs = now() - mark; + if (logits.flat().some((value) => !Number.isFinite(value))) { + throw new Error('CKKS produced a non-finite output; no plaintext fallback was used.'); + } + const argmax = (row) => row.indexOf(Math.max(...row)); + const predictions = logits.map(argmax); + const expectedPredictions = reference.logits.map(argmax); + const maxAbsoluteError = Math.max(...logits.flatMap((row, i) => ( + row.map((value, c) => Math.abs(value - reference.logits[i][c])) + ))); + const predictionAgreement = predictions.reduce((sum, prediction, i) => ( + sum + Number(prediction === expectedPredictions[i]) + ), 0) / dimensions.nodes; + timings.totalMs = now() - started; + return { + logits, + predictions, + params: { + scheme: 'CKKS', + backend: 'node-seal 7.0.0 / Microsoft SEAL 4.1.2 / WebAssembly', + polyModulusDegree: POLY_MODULUS_DEGREE, + coefficientModulusBits: [...COEFF_MODULUS_BITS], + scaleBits: 40, + slotCount: POLY_MODULUS_DEGREE / 2, + securityLevel: 'SEAL classical 128-bit parameter check; no independent concrete or post-quantum estimate', + multiplicativeLevelsConsumed: 3, + rotations: 0, + encryptedBankColumns: request.columns.flat().length, + }, + timings, + maxAbsoluteError, + predictionAgreement, + ciphertextBytes: inputCiphertextBytes + outputCiphertextBytes, + inputCiphertextBytes, + outputCiphertextBytes, + publicContextHasSecretKey: false, + scope: 'Local real CKKS: client compilation, encryption and final decryption; public evaluator in the same browser process. Public weights. No bank, key or ciphertext is sent to a remote evaluator; no process isolation or independent security audit.', + }; + } finally { + owned.dispose(); + } +} diff --git a/site/ckks.test.mjs b/site/ckks.test.mjs new file mode 100644 index 0000000..604aa2d --- /dev/null +++ b/site/ckks.test.mjs @@ -0,0 +1,49 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import { runEncrypted } from './ckks.js'; +import { evaluateDirect, perturbFeatures, validateFixture } from './krylov.js'; + +const fixture = validateFixture(JSON.parse(await readFile( + new URL('./data/demo.json', import.meta.url), 'utf8', +))); + +test('real CKKS matches an independent dense oracle at degree 0, 2 and 4', async () => { + for (const degree of [0, 2, 4]) { + const features = perturbFeatures(fixture.features, 0.07, 31); + const expected = evaluateDirect(fixture, degree, features); + const result = await runEncrypted(fixture, { degree, features }); + const maxError = Math.max(...result.logits.flatMap((row, n) => ( + row.map((value, c) => Math.abs(value - expected.logits[n][c])) + ))); + assert.ok(maxError < 1e-4, `degree ${degree}: logit error ${maxError}`); + assert.deepEqual(result.predictions, expected.predictions); + assert.equal(result.params.encryptedBankColumns, (degree + 1) * features[0].length); + assert.ok(result.inputCiphertextBytes > 0); + assert.ok(result.outputCiphertextBytes > 0); + assert.equal(result.publicContextHasSecretKey, false); + assert.equal(result.params.rotations, 0); + } +}); + +test('zero-weight circuits use real public-key zero encryption and preserve biases', async () => { + const zero = structuredClone(fixture); + zero.model.theta = zero.model.theta.map((weights) => weights.map((row) => row.map(() => 0))); + zero.model.output_weights = zero.model.output_weights.map((row) => row.map(() => 0)); + zero.model.output_bias = [0.25, -0.5]; + const result = await runEncrypted(zero); + for (const row of result.logits) { + assert.ok(Math.abs(row[0] - 0.25) < 1e-5); + assert.ok(Math.abs(row[1] + 0.5) < 1e-5); + } +}); + +test('unsupported circuits are rejected before any encrypted evaluation', async () => { + const incompatible = structuredClone(fixture); + incompatible.model.activation_coefficients = [0, 1]; + await assert.rejects(runEncrypted(incompatible), /square activation/); + await assert.rejects(runEncrypted(fixture, { degree: 5 }), /degrees from 0 to 4/); + const huge = structuredClone(fixture.features); + huge[0][0] = 9; + await assert.rejects(runEncrypted(fixture, { features: huge }), /bounded by 8/); +}); diff --git a/site/data/demo.json b/site/data/demo.json new file mode 100644 index 0000000..a27229e --- /dev/null +++ b/site/data/demo.json @@ -0,0 +1,609 @@ +{ + "schema_version": 1, + "name": "Synthetic two-community Krylov demonstration", + "nodes": [ + { + "id": 0, + "label": "Node 1" + }, + { + "id": 1, + "label": "Node 2" + }, + { + "id": 2, + "label": "Node 3" + }, + { + "id": 3, + "label": "Node 4" + }, + { + "id": 4, + "label": "Node 5" + }, + { + "id": 5, + "label": "Node 6" + }, + { + "id": 6, + "label": "Node 7" + }, + { + "id": 7, + "label": "Node 8" + }, + { + "id": 8, + "label": "Node 9" + }, + { + "id": 9, + "label": "Node 10" + }, + { + "id": 10, + "label": "Node 11" + }, + { + "id": 11, + "label": "Node 12" + }, + { + "id": 12, + "label": "Node 13" + }, + { + "id": 13, + "label": "Node 14" + }, + { + "id": 14, + "label": "Node 15" + }, + { + "id": 15, + "label": "Node 16" + }, + { + "id": 16, + "label": "Node 17" + }, + { + "id": 17, + "label": "Node 18" + }, + { + "id": 18, + "label": "Node 19" + }, + { + "id": 19, + "label": "Node 20" + }, + { + "id": 20, + "label": "Node 21" + }, + { + "id": 21, + "label": "Node 22" + }, + { + "id": 22, + "label": "Node 23" + }, + { + "id": 23, + "label": "Node 24" + } + ], + "edges": [ + [ + 0, + 1 + ], + [ + 0, + 2 + ], + [ + 0, + 10 + ], + [ + 0, + 11 + ], + [ + 1, + 2 + ], + [ + 1, + 3 + ], + [ + 1, + 11 + ], + [ + 2, + 3 + ], + [ + 2, + 4 + ], + [ + 2, + 14 + ], + [ + 3, + 4 + ], + [ + 3, + 5 + ], + [ + 4, + 5 + ], + [ + 4, + 6 + ], + [ + 5, + 6 + ], + [ + 5, + 7 + ], + [ + 6, + 7 + ], + [ + 6, + 8 + ], + [ + 7, + 8 + ], + [ + 7, + 9 + ], + [ + 7, + 19 + ], + [ + 8, + 9 + ], + [ + 8, + 10 + ], + [ + 9, + 10 + ], + [ + 9, + 11 + ], + [ + 10, + 11 + ], + [ + 10, + 22 + ], + [ + 12, + 13 + ], + [ + 12, + 14 + ], + [ + 12, + 22 + ], + [ + 12, + 23 + ], + [ + 13, + 14 + ], + [ + 13, + 15 + ], + [ + 13, + 23 + ], + [ + 14, + 15 + ], + [ + 14, + 16 + ], + [ + 15, + 16 + ], + [ + 15, + 17 + ], + [ + 16, + 17 + ], + [ + 16, + 18 + ], + [ + 17, + 18 + ], + [ + 17, + 19 + ], + [ + 18, + 19 + ], + [ + 18, + 20 + ], + [ + 19, + 20 + ], + [ + 19, + 21 + ], + [ + 20, + 21 + ], + [ + 20, + 22 + ], + [ + 21, + 22 + ], + [ + 21, + 23 + ], + [ + 22, + 23 + ] + ], + "features": [ + [ + 0.10569434942821598, + -0.079870531760649, + 0.25 + ], + [ + -0.3138627978446022, + -0.2735035530723097, + 0.25 + ], + [ + -0.796984333389427, + -0.9997199757940578, + 0.25 + ], + [ + -1.193133021268838, + 0.5091411372226218, + 0.25 + ], + [ + -1.5420416984115906, + -0.961242080854082, + 0.25 + ], + [ + -0.4897489399591929, + -0.9285399988106673, + 0.25 + ], + [ + -0.9458119047313815, + -0.6466393469065284, + 0.25 + ], + [ + -0.9796857779639823, + -0.002302065843778983, + 0.25 + ], + [ + -0.6220832288961401, + 0.4286043773205947, + 0.25 + ], + [ + -0.5285153154726098, + -0.28239526840653034, + 0.25 + ], + [ + -1.752230548676707, + -0.33687707269057643, + 0.25 + ], + [ + 0.008830624817034671, + -0.13913448749927448, + 0.25 + ], + [ + -0.6147650832505143, + 0.07159908986109206, + 0.25 + ], + [ + 1.5227932597048908, + 0.22882782861077472, + 0.25 + ], + [ + 0.8248124613799244, + -0.19899001077705258, + 0.25 + ], + [ + 0.2404855150266772, + 0.35339010141691807, + 0.25 + ], + [ + 1.2386480955594257, + 0.3374027893546054, + 0.25 + ], + [ + 0.5114325540545936, + -0.6523960957203829, + 0.25 + ], + [ + 0.8800065321928063, + 0.16142091913665987, + 0.25 + ], + [ + 0.5589748936959628, + 0.25702053618654697, + 0.25 + ], + [ + 1.1030922590716514, + 0.3964286660920666, + 0.25 + ], + [ + 1.0837823499067063, + -0.08563141475389535, + 0.25 + ], + [ + 0.0009662222338138182, + -0.2648119924918734, + 0.25 + ], + [ + 0.16312952185601126, + -0.1327569281782051, + 0.25 + ] + ], + "labels": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1, + 1 + ], + "model": { + "theta": [ + [ + [ + -0.13650135710796923, + 0.1365014165352831, + -0.10725106938625388, + 0.10725110990487524 + ], + [ + 0.0032569800725079966, + -0.003257146552988788, + 0.0025590644195946735, + -0.0025591779290118797 + ], + [ + 8.208255335519005e-07, + 1.012505979708333e-06, + 5.496963463091221e-07, + 7.003024119709708e-07 + ] + ], + [ + [ + -0.18401742670443236, + 0.18401750007020212, + -0.14458512479326005, + 0.14458517481537841 + ], + [ + 0.004025245456982251, + -0.0040255620592724, + 0.0031627093059299945, + -0.0031629251711279083 + ], + [ + -0.0068488547524084885, + 0.006849910489349011, + -0.005381297863235421, + 0.005382017683867687 + ] + ], + [ + [ + -0.17846104514798636, + 0.17846109160706464, + -0.14021939502973596, + 0.1402194267063781 + ], + [ + -0.023773130616121317, + 0.023772873351262398, + -0.018678874976827905, + 0.018678699568971197 + ], + [ + -0.006352033652754131, + 0.00635249571883422, + -0.004990907587740769, + 0.0049912226327952945 + ] + ], + [ + [ + -0.116794856859463, + 0.11679487886743062, + -0.09176738867570562, + 0.0917674036811387 + ], + [ + -0.04335223213107853, + 0.04335210431999992, + -0.03406246146345318, + 0.03406237431953625 + ], + [ + -0.0021381137626168556, + 0.0021383594345332235, + -0.0016799592899488115, + 0.001680126793527774 + ] + ], + [ + [ + -0.06854686299845182, + 0.06854686297298353, + -0.05385824949746076, + 0.05385824948009577 + ], + [ + -0.036845708335019704, + 0.0368456175513891, + -0.028950194690053856, + 0.028950132792123988 + ], + [ + -0.0005371784720755753, + 0.0005373289380465885, + -0.00042207661590223163, + 0.00042217920633680264 + ] + ] + ], + "bias": [ + 0.492495316684085, + 0.6075035835284197, + 0.3298178059550312, + 0.4201814441898615 + ], + "activation_coefficients": [ + 0.0, + 0.0, + 1.0 + ], + "output_weights": [ + [ + 0.7489132825113839, + -0.7489080770655298 + ], + [ + -0.28272918625374877, + 0.28273432549739425 + ], + [ + 0.42023091369039445, + -0.42023000893791673 + ], + [ + -0.13243469457671206, + 0.13243556386374905 + ] + ], + "output_bias": [ + 0.24443165224962024, + 0.755563611166524 + ] + }, + "default_degree": 2, + "provenance": { + "implementation": "Independent NumPy implementation; no upstream code copied", + "mathematical_source_url": "https://github.com/chimans/PrivNet-Krylov", + "source_commit": "0976e41881192da938ef2ab988c2fe3fdb92835b", + "dataset": "Public synthetic two-community graph generated locally", + "seed": 17, + "training": "Two ridge fits with square activation; all nodes used", + "training_degree": 4, + "maximum_degree": 4, + "evaluation_scope": "Illustrative all-node/training accuracy; no held-out split", + "normalization": "S = D^-1/2 (A + I) D^-1/2; undirected unweighted edges", + "degree_selection": "Truncates a fixed trained theta bank; does not retrain", + "privacy": "Browser artifact is entirely public; browser arithmetic is plaintext", + "benchmark_claim": "No upstream accuracy, runtime, security or PQ claim is reproduced" + } +} diff --git a/site/index.html b/site/index.html new file mode 100644 index 0000000..4cfa885 --- /dev/null +++ b/site/index.html @@ -0,0 +1,148 @@ + + + + + + + + PrivnetAI Β· Krylov laboratory + + + + +
+ + + PrivnetAI/laboratory + + +
+ +
+
+
+
GRAPH LEARNING, MADE INSPECTABLE
+

Compile the graph.
Inspect the computation.

+

Turn graph propagation into a reusable feature bank. Adjust its degree, perturb the inputs, and compare two independent paths through the same model.

+
+ +
+ + + +
+
+
+
+
01 / THE GRAPH

A graph you can inspect

+
+ + +
+
+
Loading graph…BUNDLED DEMO
+
+ + + Interactive demonstration graph + Select a node to inspect its model scores. Node positions are illustrative; edges define the graph. + +
Select a node to inspect its scores
+
+
Layout groups reference labels; positions are illustrative.
+
+ +
+
02 / CLIENT COMPILATION

From edges to a feature bank

PLAINTEXT
+
+
1
NormalizeS = D⁻½(A + I)D⁻½Undirected graph · one self loop
+ +
2
Compile
Bβ‚€ = X Β· B₁ = SX
+ +
3
Evaluatep(Ξ£ Bβ‚–Ξ˜β‚– + b)W + cPolynomial activation Β· linear readout
+
+

Chebyshev recurrence: Bβ‚– = 2SBₖ₋₁ βˆ’ Bβ‚–β‚‹β‚‚. The independent oracle constructs the full Tβ‚–(S) matrices. Both paths use identical inputs and weights.

+
+
+ + +
+ +
+
SAME-MODEL AGREEMENTβ€”%Compiled vs direct oracle
+
MAXIMUM LOGIT ERRORβ€”Absolute floating-point difference
+
ALL-NODE ACCURACYβ€”%Illustrative Β· no held-out evaluation
+
BANK SCALAR VALUESβ€”(K + 1) Γ— nodes Γ— features
+
+ +
+
+
03 / INSPECT A NODE

Node scores

β€”
+
+

Raw logits are model scores, not calibrated probabilities. The largest score determines the prediction.

+
+
+
COMPUTATION DETAILS

Measured in your browser

LOCAL
+
Compile the bank
β€”
Evaluate compiled readout
β€”
Independent dense oracle
β€”
Mean absolute logit error
β€”
+

One local run, including browser noise. These timings do not measure encrypted execution, a server, or a research benchmark.

+ +
+
+ + + +
+
WHY A FEATURE BANK?

Move the graph work.
Keep the model explicit.

+

For a fixed graph operator, the client can compute Chebyshev-transformed features once. The readout then uses dense projections of those features, a polynomial activation, and a final classifier.

This laboratory checks the algebra with an independent direct oracle. It demonstrates numerical equivalence for the selected model; it does not establish accuracy on unseen data, conceal the default plaintext bank, or prove security.

Read the findings and proposed roadmap

Method reference: PrivNet-Krylov by Ehsan Hakimian and Chiman Soltanian β†—. This browser implementation was written independently; upstream source code is not bundled.

+
+
+ + + + diff --git a/site/krylov.js b/site/krylov.js new file mode 100644 index 0000000..4e9eb40 --- /dev/null +++ b/site/krylov.js @@ -0,0 +1,229 @@ +/** + * Independent plaintext Chebyshev graph compilation, implemented for this demo. + * No cryptographic operations occur here. S = D^-1/2 (A + I) D^-1/2. + * Bank: B0 = X, B1 = SX, Bk = 2 S B(k-1) - B(k-2). + */ + +const LIMITS = Object.freeze({ nodes: 120, features: 32, hidden: 32, classes: 16, degree: 4 }); +export { LIMITS }; + +const zeros = (rows, columns) => Array.from({ length: rows }, () => Array(columns).fill(0)); +const copyMatrix = (matrix) => matrix.map((row) => row.slice()); +const now = () => globalThis.performance?.now?.() ?? Date.now(); + +function requireCondition(condition, message) { + if (!condition) throw new Error(message); +} + +function finiteNumber(value, context) { + requireCondition(typeof value === 'number' && Number.isFinite(value), `${context} must contain finite numbers.`); + requireCondition(Math.abs(value) <= 1e6, `${context} exceeds the supported magnitude (1,000,000).`); + return value; +} + +function vector(value, size, context) { + requireCondition(Array.isArray(value) && value.length === size, `${context} must have ${size} entries.`); + return value.map((item) => finiteNumber(item, context)); +} + +function matrix(value, rows, columns, context) { + requireCondition(Array.isArray(value) && value.length === rows, `${context} must have ${rows} rows.`); + return value.map((row) => vector(row, columns, context)); +} + +/** Validate and copy a bounded fixture. Duplicate undirected edges are deduplicated. */ +export function validateFixture(raw) { + requireCondition(raw && typeof raw === 'object' && !Array.isArray(raw), 'The JSON must be an object.'); + requireCondition(raw.schema_version === 1, 'Only fixture schema_version 1 is supported.'); + requireCondition(Array.isArray(raw.nodes) && raw.nodes.length > 0 && raw.nodes.length <= LIMITS.nodes, + `Provide between 1 and ${LIMITS.nodes} nodes.`); + const nodeCount = raw.nodes.length; + const nodes = raw.nodes.map((node, index) => { + requireCondition(node && typeof node === 'object', `Node ${index} must be an object.`); + requireCondition(node.id === index, `Node ids must be consecutive integers 0 through ${nodeCount - 1}, in array order.`); + requireCondition(typeof node.label === 'string' && node.label.length <= 100, + `Node ${index} needs a label of at most 100 characters.`); + return { id: index, label: node.label }; + }); + requireCondition(Array.isArray(raw.edges) && raw.edges.length <= nodeCount * nodeCount, + 'edges must be an array of node-index pairs.'); + const seen = new Set(); + const edges = []; + for (const edge of raw.edges) { + requireCondition(Array.isArray(edge) && edge.length === 2 && edge.every((v) => Number.isInteger(v) && v >= 0 && v < nodeCount), + 'Each edge must contain two valid node indices.'); + requireCondition(edge[0] !== edge[1], 'Omit self loops: the compiler adds one self loop per node.'); + const pair = [Math.min(...edge), Math.max(...edge)]; + const key = pair.join(':'); + if (!seen.has(key)) { seen.add(key); edges.push(pair); } + } + requireCondition(Array.isArray(raw.features) && Array.isArray(raw.features[0]), 'features must be a rectangular numeric matrix.'); + const featureCount = raw.features[0].length; + requireCondition(featureCount > 0 && featureCount <= LIMITS.features, `Use 1–${LIMITS.features} input features.`); + const features = matrix(raw.features, nodeCount, featureCount, 'features'); + const model = raw.model; + requireCondition(model && typeof model === 'object' && Array.isArray(model.theta) && model.theta.length > 0, + 'model.theta must contain at least the degree-zero weight matrix.'); + requireCondition(model.theta.length <= LIMITS.degree + 1, `The maximum supported degree is ${LIMITS.degree}.`); + requireCondition(Array.isArray(model.bias) && model.bias.length > 0 && model.bias.length <= LIMITS.hidden, + `Use 1–${LIMITS.hidden} hidden channels.`); + const hiddenCount = model.bias.length; + const theta = model.theta.map((weights, k) => matrix(weights, featureCount, hiddenCount, `model.theta[${k}]`)); + const bias = vector(model.bias, hiddenCount, 'model.bias'); + requireCondition(Array.isArray(model.activation_coefficients) && model.activation_coefficients.length > 0 && model.activation_coefficients.length <= 9, + 'Provide 1–9 polynomial activation coefficients in ascending order.'); + const activationCoefficients = vector(model.activation_coefficients, model.activation_coefficients.length, 'model.activation_coefficients'); + requireCondition(Array.isArray(model.output_bias) && model.output_bias.length > 0 && model.output_bias.length <= LIMITS.classes, + `Use 1–${LIMITS.classes} output classes.`); + const classCount = model.output_bias.length; + const outputWeights = matrix(model.output_weights, hiddenCount, classCount, 'model.output_weights'); + const outputBias = vector(model.output_bias, classCount, 'model.output_bias'); + requireCondition(Array.isArray(raw.labels) && raw.labels.length === nodeCount && raw.labels.every((label) => Number.isInteger(label) && label >= 0 && label < classCount), + 'labels must contain one valid class index per node.'); + requireCondition(Number.isInteger(raw.default_degree) && raw.default_degree >= 0 && raw.default_degree < theta.length, + 'default_degree must select an available theta prefix.'); + requireCondition(raw.name === undefined || (typeof raw.name === 'string' && raw.name.length <= 120), 'name must be at most 120 characters.'); + return { + schema_version: 1, name: raw.name || 'Custom graph', nodes, edges, features, + labels: raw.labels.slice(), default_degree: raw.default_degree, + model: { theta, bias, activation_coefficients: activationCoefficients, output_weights: outputWeights, output_bias: outputBias }, + provenance: raw.provenance && typeof raw.provenance === 'object' ? raw.provenance : {}, + }; +} + +/** Positive symmetric normalization; no implicit weighting or duplicate counting. */ +export function normalizedShift(nodeCount, edges) { + requireCondition(Number.isInteger(nodeCount) && nodeCount > 0 && nodeCount <= LIMITS.nodes, 'Invalid graph size.'); + const adjacency = zeros(nodeCount, nodeCount); + for (let i = 0; i < nodeCount; i++) adjacency[i][i] = 1; + for (const [a, b] of edges) { + requireCondition(Number.isInteger(a) && Number.isInteger(b) && a >= 0 && b >= 0 && a < nodeCount && b < nodeCount, 'Invalid edge index.'); + adjacency[a][b] = 1; + adjacency[b][a] = 1; + } + const degrees = adjacency.map((row) => row.reduce((sum, value) => sum + value, 0)); + return adjacency.map((row, i) => row.map((value, j) => value / Math.sqrt(degrees[i] * degrees[j]))); +} + +export function multiply(a, b) { + const result = zeros(a.length, b[0].length); + for (let i = 0; i < a.length; i++) { + for (let k = 0; k < b.length; k++) { + if (a[i][k] === 0) continue; + for (let j = 0; j < b[0].length; j++) result[i][j] += a[i][k] * b[k][j]; + } + } + return result; +} + +function checkDegree(fixture, degree) { + requireCondition(Number.isInteger(degree) && degree >= 0 && degree < fixture.model.theta.length && degree <= LIMITS.degree, + 'Choose an available polynomial degree.'); +} + +/** Returns bank[k][node][feature]; never mutates features or model weights. */ +export function compileBank(fixture, degree = fixture.default_degree, features = fixture.features) { + checkDegree(fixture, degree); + const shift = normalizedShift(fixture.nodes.length, fixture.edges); + const bank = [copyMatrix(features)]; + if (degree > 0) bank.push(multiply(shift, bank[0])); + for (let k = 2; k <= degree; k++) { + const shifted = multiply(shift, bank[k - 1]); + bank.push(shifted.map((row, i) => row.map((value, j) => 2 * value - bank[k - 2][i][j]))); + } + return bank; +} + +function finish(fixture, preactivation) { + const coefficients = fixture.model.activation_coefficients; + const activation = preactivation.map((row) => row.map((value) => { + let result = 0; + for (let p = coefficients.length - 1; p >= 0; p--) result = result * value + coefficients[p]; + return result; + })); + const logits = multiply(activation, fixture.model.output_weights) + .map((row) => row.map((value, c) => value + fixture.model.output_bias[c])); + requireCondition(logits.every((row) => row.every(Number.isFinite)), 'This input produces nonfinite outputs; reduce its magnitudes or polynomial coefficients.'); + const predictions = logits.map((row) => row.reduce((best, value, c) => value > row[best] ? c : best, 0)); + return { preactivation, activation, logits, predictions }; +} + +/** Bank projection and polynomial readout; graph operations are absent from this step. */ +export function evaluateCompiled(fixture, bank, degree = bank.length - 1) { + checkDegree(fixture, degree); + requireCondition(bank.length >= degree + 1, 'The feature bank is incomplete.'); + const preactivation = Array.from({ length: fixture.nodes.length }, () => fixture.model.bias.slice()); + for (let k = 0; k <= degree; k++) { + const projected = multiply(bank[k], fixture.model.theta[k]); + for (let n = 0; n < preactivation.length; n++) { + for (let h = 0; h < preactivation[n].length; h++) preactivation[n][h] += projected[n][h]; + } + } + return finish(fixture, preactivation); +} + +/** + * Independent dense oracle: construct T_k(S) as full NΓ—N matrices, then apply X. + * It does not call compileBank or evaluateCompiled. Both paths use the same model. + */ +export function evaluateDirect(fixture, degree = fixture.default_degree, features = fixture.features) { + checkDegree(fixture, degree); + const n = fixture.nodes.length; + const shift = normalizedShift(n, fixture.edges); + let previous = zeros(n, n); + for (let i = 0; i < n; i++) previous[i][i] = 1; + let current = shift; + const preactivation = Array.from({ length: n }, () => fixture.model.bias.slice()); + for (let k = 0; k <= degree; k++) { + let polynomial; + if (k === 0) polynomial = previous; + else if (k === 1) polynomial = current; + else { + const product = multiply(shift, current); + polynomial = product.map((row, i) => row.map((value, j) => 2 * value - previous[i][j])); + previous = current; + current = polynomial; + } + const projection = multiply(multiply(polynomial, features), fixture.model.theta[k]); + for (let i = 0; i < n; i++) { + for (let h = 0; h < preactivation[i].length; h++) preactivation[i][h] += projection[i][h]; + } + } + return finish(fixture, preactivation); +} + +/** Deterministic, bounded perturbation; repeat runs with the same setting match. */ +export function perturbFeatures(features, amount = 0, seed = 17) { + requireCondition(Number.isFinite(amount) && amount >= 0 && amount <= 1, 'Perturbation must be between 0 and 1.'); + let state = seed >>> 0; + const random = () => { + state = (Math.imul(1664525, state) + 1013904223) >>> 0; + return state / 4294967296; + }; + return features.map((row) => row.map((value) => value + amount * (2 * random() - 1))); +} + +export function runExperiment(fixture, degree = fixture.default_degree, perturbation = 0) { + checkDegree(fixture, degree); + const features = perturbFeatures(fixture.features, perturbation); + const t0 = now(); + const bank = compileBank(fixture, degree, features); + const t1 = now(); + const compiled = evaluateCompiled(fixture, bank, degree); + const t2 = now(); + const direct = evaluateDirect(fixture, degree, features); + const t3 = now(); + const errors = compiled.logits.flatMap((row, n) => row.map((value, c) => Math.abs(value - direct.logits[n][c]))); + const agreement = compiled.predictions.filter((prediction, n) => prediction === direct.predictions[n]).length / fixture.nodes.length; + const accuracy = compiled.predictions.filter((prediction, n) => prediction === fixture.labels[n]).length / fixture.nodes.length; + return { + schema_version: 1, mode: 'Local plaintext demonstration', encrypted: false, + fixture_name: fixture.name, degree, perturbation, feature_seed: 17, + graph: { nodes: fixture.nodes.length, edges: fixture.edges.length, features: fixture.features[0].length }, + bank_scalar_count: (degree + 1) * fixture.nodes.length * fixture.features[0].length, + timing_ms: { compile: t1 - t0, compiled_inference: t2 - t1, direct_oracle: t3 - t2 }, + diagnostics: { max_absolute_logit_error: Math.max(...errors), mean_absolute_logit_error: errors.reduce((sum, value) => sum + value, 0) / errors.length, + prediction_agreement: agreement, illustrative_all_node_accuracy: accuracy }, + compiled, direct, nodes: fixture.nodes, labels: fixture.labels, provenance: fixture.provenance, + }; +} diff --git a/site/krylov.test.mjs b/site/krylov.test.mjs new file mode 100644 index 0000000..b2eedbb --- /dev/null +++ b/site/krylov.test.mjs @@ -0,0 +1,93 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { validateFixture, normalizedShift, compileBank, evaluateCompiled, evaluateDirect, perturbFeatures, runExperiment } from './krylov.js'; + +function fixture() { + return validateFixture({ schema_version: 1, name: 'Three-node chain', nodes: [{ id: 0, label: 'A' }, { id: 1, label: 'B' }, { id: 2, label: 'C' }], + edges: [[0, 1], [1, 2]], features: [[1, 0], [0, 1], [2, -1]], labels: [0, 1, 0], default_degree: 2, + model: { theta: Array.from({ length: 5 }, (_, k) => [[0.2 + k * 0.03, -0.1], [0.4, 0.3 - k * 0.02]]), bias: [0.1, -0.03], + activation_coefficients: [0.1, 0.7, 0.2], output_weights: [[0.6, -0.4], [-0.2, 0.3]], output_bias: [0.01, 0.02] } }); +} + +function close(actual, expected, tolerance = 1e-12) { + assert.equal(actual.length, expected.length); + actual.forEach((row, i) => row.forEach((value, j) => assert.ok(Math.abs(value - expected[i][j]) < tolerance, `${i},${j}: ${value} vs ${expected[i][j]}`))); +} + +test('normalization adds self loops, deduplicates edges, and preserves isolated nodes', () => { + close(normalizedShift(3, [[0, 1], [1, 0], [0, 1]]), [[0.5, 0.5, 0], [0.5, 0.5, 0], [0, 0, 1]]); + const shift = normalizedShift(3, [[0, 1], [1, 2]]); + assert.ok(Math.abs(shift[0][1] - 1 / Math.sqrt(6)) < 1e-15); + close(shift, shift[0].map((_, i) => shift.map((row) => row[i]))); +}); + +test('degree-zero bank is an independent copy and degree-two follows Chebyshev recurrence', () => { + const data = fixture(); + const bank = compileBank(data, 2); + close(bank[0], data.features); + assert.notEqual(bank[0], data.features); + assert.notEqual(bank[0][0], data.features[0]); + const shift = normalizedShift(data.nodes.length, data.edges); + const manual = data.features.map((row, i) => row.map((value, f) => { + let shiftedTwice = 0; + for (let a = 0; a < 3; a++) for (let b = 0; b < 3; b++) shiftedTwice += shift[i][a] * shift[a][b] * data.features[b][f]; + return 2 * shiftedTwice - value; + })); + close(bank[2], manual); +}); + +test('compiled feature recurrence agrees with independent dense-matrix oracle at every degree', () => { + const data = fixture(); + const original = JSON.stringify(data); + for (let degree = 0; degree <= 4; degree++) { + const features = perturbFeatures(data.features, 0.17); + const compiled = evaluateCompiled(data, compileBank(data, degree, features), degree); + const direct = evaluateDirect(data, degree, features); + close(compiled.preactivation, direct.preactivation); + close(compiled.logits, direct.logits); + assert.deepEqual(compiled.predictions, direct.predictions); + } + assert.equal(JSON.stringify(data), original); +}); + +test('affine and polynomial readout has an independently calculable scalar result', () => { + const data = validateFixture({ schema_version: 1, nodes: [{ id: 0, label: 'Only node' }], edges: [], features: [[2]], labels: [0], default_degree: 0, + model: { theta: [[[3]]], bias: [1], activation_coefficients: [0, 0, 1], output_weights: [[2, -1]], output_bias: [0.5, 3] } }); + const result = evaluateCompiled(data, compileBank(data, 0)); + close(result.preactivation, [[7]]); + close(result.activation, [[49]]); + close(result.logits, [[98.5, -46]]); + assert.deepEqual(result.predictions, [0]); +}); + +test('perturbations are deterministic, bounded and leave the input unchanged', () => { + const input = [[1, 2], [-3, 4]]; + assert.deepEqual(perturbFeatures(input, 0), input); + const perturbed = perturbFeatures(input, 0.2); + assert.deepEqual(perturbed, perturbFeatures(input, 0.2)); + perturbed.forEach((row, i) => row.forEach((value, j) => assert.ok(Math.abs(value - input[i][j]) <= 0.2))); + assert.deepEqual(input, [[1, 2], [-3, 4]]); +}); + +test('fixture validation rejects malformed, oversized and nonfinite inputs', () => { + const data = fixture(); + assert.throws(() => validateFixture({ ...data, schema_version: 99 }), /schema_version/); + assert.throws(() => validateFixture({ ...data, features: [[NaN], [0], [1]] }), /finite/); + assert.throws(() => validateFixture({ ...data, edges: [[0, 3]] }), /valid node/); + assert.throws(() => validateFixture({ ...data, nodes: [...data.nodes, ...data.nodes] }), /consecutive/); + assert.throws(() => validateFixture({ ...data, features: [[1], [2, 3], [4]] }), /entries/); + assert.throws(() => validateFixture({ ...data, nodes: Array(121).fill({ id: 'a', label: 'A' }) }), /120/); + assert.throws(() => validateFixture({ ...data, labels: [0, 9, 0] }), /class index/); + assert.throws(() => validateFixture({ ...data, default_degree: 5 }), /theta prefix/); + assert.throws(() => compileBank(data, 5), /degree/); +}); + +test('experiment diagnostics identify plaintext mode and count the bank accurately', () => { + const result = runExperiment(fixture(), 3, 0.2); + assert.equal(result.encrypted, false); + assert.equal(result.mode, 'Local plaintext demonstration'); + assert.equal(result.bank_scalar_count, 4 * 3 * 2); + assert.equal(result.diagnostics.prediction_agreement, 1); + assert.ok(result.diagnostics.max_absolute_logit_error < 1e-12); + for (const value of Object.values(result.timing_ms)) assert.ok(value >= 0); +}); diff --git a/site/styles.css b/site/styles.css new file mode 100644 index 0000000..2ffbbd1 --- /dev/null +++ b/site/styles.css @@ -0,0 +1,1381 @@ +:root { + font-family: "DM Sans", system-ui, sans-serif; + color: #162e2c; + background: #f5f7f3; + font-synthesis: none; + font-weight: 400; + --ink: #162e2c; + --muted: #6b7b74; + --green: #327b63; + --mint: #e6f1e7; + --border: #dbe3dc; + --orange: #c77747; + --panel: #fff; + --shadow: 0 5px 24px #20362905; +} +* { + box-sizing: border-box; +} +body { + margin: 0; +} +button, +input, +select { + font: inherit; +} +button, +a, +input { + -webkit-tap-highlight-color: transparent; +} +button { + cursor: pointer; +} +button:disabled { + cursor: wait; + opacity: 0.55; +} +a { + color: inherit; + text-decoration: none; +} +a:hover { + text-decoration: underline; +} +button:focus-visible, +a:focus-visible, +summary:focus-visible { + outline: 3px solid #52a58b; + outline-offset: 4px; +} +input:focus-visible { + outline: 3px solid #52a58b; + outline-offset: 5px; +} +[hidden] { + display: none !important; +} +svg { + display: block; +} +.topbar { + height: 90px; + max-width: 1440px; + margin: auto; + padding: 0 5.5%; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--border); +} +.brand { + display: flex; + align-items: center; + gap: 10px; + font-family: "Space Grotesk", sans-serif; + font-size: 22px; + font-weight: 600; + letter-spacing: -0.8px; +} +.brand:hover { + text-decoration: none; +} +.brand svg { + width: 32px; + height: 32px; + stroke: var(--ink); + stroke-width: 1.4; + fill: none; +} +.brand-accent { + color: var(--green); +} +.brand-divider { + margin: 0 13px; + color: #b3c1b6; + font-weight: 400; +} +.brand-lab { + font-size: 15px; + font-weight: 500; + letter-spacing: -0.1px; + color: var(--muted); +} +nav { + display: flex; + align-items: center; + gap: 29px; + font-size: 13px; + font-weight: 600; +} +nav a span { + padding-left: 5px; + color: var(--muted); +} +.local-badge { + display: flex; + align-items: center; + gap: 8px; + background: #e8eee6; + border: 1px solid #d9e3d8; + padding: 8px 12px; + border-radius: 100px; + font-size: 11px; + color: #47634d; +} +.status-dot { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + background: #559875; + box-shadow: 0 0 0 3px #55987513; + flex-shrink: 0; +} +main { + max-width: 1440px; + margin: auto; + padding: 0 5.5%; +} +.hero { + padding: 58px 0 44px; + display: flex; + gap: 70px; + align-items: center; + justify-content: space-between; +} +.hero-copy { + max-width: 760px; +} +.eyebrow { + display: flex; + align-items: center; + gap: 11px; + font-size: 10px; + font-weight: 700; + letter-spacing: 2px; + color: #597460; + margin-bottom: 18px; +} +.line { + height: 1px; + background: #7e9b83; + width: 29px; +} +h1 { + font-family: "Space Grotesk", sans-serif; + font-weight: 500; + font-size: clamp(36px, 3.5vw, 52px); + line-height: 1.12; + letter-spacing: -2.2px; + margin: 0 0 20px; +} +h1 span { + color: #658071; +} +.hero-copy > p { + max-width: 620px; + font-size: 14px; + line-height: 1.85; + color: var(--muted); + margin: 0; +} +.mode-card { + border: 1px solid var(--border); + border-radius: 12px; + padding: 21px 23px; + max-width: 322px; + min-width: 290px; + position: relative; + background: linear-gradient(135deg, #edf2eaf0, #f8faf5); +} +.small-label { + font-size: 9px; + letter-spacing: 1.3px; + font-weight: 700; + color: #829087; + display: block; +} +.mode-card strong { + font-size: 13px; + display: flex; + gap: 10px; + align-items: center; + margin: 14px 0 11px; +} +.mode-card p { + font-size: 12px; + line-height: 1.8; + color: var(--muted); + margin: 0; +} +.mode-footer { + font-size: 10px; + margin-top: 17px; + border-top: 1px solid var(--border); + padding-top: 12px; + color: #829087; +} +.mode-footer span { + margin-right: 5px; +} +.workspace { + display: grid; + grid-template-columns: minmax(0, 1fr) 305px; + gap: 20px; + align-items: start; +} +.workspace-main { + min-width: 0; + display: grid; + gap: 20px; +} +.panel { + border: 1px solid var(--border); + border-radius: 13px; + background: var(--panel); + box-shadow: var(--shadow); + overflow: hidden; +} +.panel-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 24px 25px 16px; +} +.panel-heading h2 { + font-family: "Space Grotesk", sans-serif; + font-weight: 500; + font-size: 18px; + letter-spacing: -0.55px; + margin: 7px 0 0; +} +.graph-view-switch { + display: flex; + background: #f3f6f0; + padding: 3px; + border-radius: 7px; + gap: 2px; +} +.view-button { + background: none; + border: 0; + border-radius: 5px; + font-size: 10px; + font-weight: 550; + color: #7d8a80; + padding: 7px 10px; +} +.view-button.active { + background: white; + color: var(--ink); + box-shadow: 0 1px 4px #10291c10; +} +.graph-meta { + display: flex; + align-items: center; + gap: 12px; + padding: 0 25px 15px; + font-size: 10px; + color: #8b958d; + flex-wrap: wrap; +} +.graph-meta strong { + font-weight: 500; + color: #577561; +} +.tag { + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid #dbe7dc; + border-radius: 4px; + background: #edf4eb; + padding: 4px 6px; + font-size: 8px; + font-weight: 600; + letter-spacing: 0.7px; + color: #63856e; + white-space: nowrap; +} +.tag.outline { + background: transparent; + color: #8a978d; + border-color: #e1e6e0; +} +.graph-stage { + height: 350px; + position: relative; + background: #fafcf8; + overflow: hidden; + border-top: 1px solid #eef1eb; + border-bottom: 1px solid #eef1eb; +} +.graph-grid { + position: absolute; + inset: 0; + background-image: radial-gradient(#a9b8a148 0.8px, transparent 0.8px); + background-size: 21px 21px; + mask-image: linear-gradient( + to bottom, + transparent, + #000 12%, + #000 85%, + transparent + ); +} +#graph { + position: relative; + width: 100%; + height: 100%; + z-index: 1; +} +.graph-edge { + stroke: #cad7c9; + stroke-width: 1.5; + opacity: 0.75; + transition: + stroke 0.2s, + opacity 0.2s; +} +.graph-edge.highlight { + stroke: #799d86; + stroke-width: 2.2; + opacity: 1; +} +.graph-node { + cursor: pointer; + outline: none; +} +.graph-node circle { + stroke: white; + stroke-width: 3; + transition: + fill 0.25s, + r 0.2s; + filter: drop-shadow(0 2px 3px #25432c15); +} +.graph-node .selection-ring { + fill: none; + stroke: #4e7960; + stroke-width: 1.3; + stroke-dasharray: 3 3; + opacity: 0; +} +.graph-node.selected .selection-ring, +.graph-node:focus-visible .selection-ring { + opacity: 1; +} +.graph-node text { + font-family: "DM Sans", sans-serif; + fill: #fff; + font-size: 9px; + font-weight: 650; + text-anchor: middle; + dominant-baseline: central; + pointer-events: none; +} +.graph-node .node-caption { + fill: #738275; + font-size: 8px; + font-weight: 450; +} +.graph-hint { + position: absolute; + bottom: 13px; + left: 20px; + font-size: 9px; + color: #93a089; + background: #fafcf8e0; + padding: 4px 6px; + border-radius: 4px; + pointer-events: none; +} +.graph-hint span { + margin-right: 5px; +} +.graph-bottom { + display: flex; + align-items: center; + justify-content: space-between; + gap: 15px; + padding: 16px 25px; +} +.legend { + display: flex; + gap: 14px; + flex-wrap: wrap; +} +.legend-item { + display: flex; + gap: 6px; + align-items: center; + font-size: 9px; + color: #768678; +} +.legend-dot { + width: 7px; + height: 7px; + border-radius: 50%; +} +.small-note { + font-size: 10px; + line-height: 1.8; + color: #8c968e; + margin: 0; +} +.pipeline-panel .panel-heading { + padding-bottom: 21px; +} +.pipeline { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 25px 23px; +} +.pipeline-step { + display: flex; + flex-direction: column; + gap: 9px; + flex: 1; + min-width: 0; +} +.step-number { + width: 19px; + height: 19px; + display: flex; + align-items: center; + justify-content: center; + background: #f1f5ee; + border: 1px solid #e2eadc; + border-radius: 5px; + font-size: 9px; + color: #8ca07f; +} +.pipeline-step strong { + font-size: 11px; + font-weight: 600; +} +.pipeline-step code { + font-size: 10px; + font-family: monospace; + letter-spacing: -0.4px; + white-space: nowrap; +} +.pipeline-step > span { + color: #919e91; + font-size: 8px; +} +.pipeline-arrow { + color: #b8c7b3; + font-size: 18px; + padding: 0 15px 0 0; + flex: 0; +} +.bank-tiles { + display: flex; + gap: 5px; + min-height: 17px; +} +.bank-tile { + font-family: monospace; + font-size: 9px; + background: #e9f1e7; + border: 1px solid #d8e5d1; + color: #6b8762; + padding: 3px 6px; + border-radius: 3px; + animation: tile-in 0.3s ease both; +} +.bank-tile:nth-child(2) { + animation-delay: 0.05s; +} +.bank-tile:nth-child(3) { + animation-delay: 0.1s; +} +.bank-tile:nth-child(4) { + animation-delay: 0.15s; +} +.bank-tile:nth-child(5) { + animation-delay: 0.2s; +} +.pipeline-note { + margin: 0; + padding: 13px 25px 16px; + border-top: 1px solid #eef1eb; + color: #929d92; + font-size: 9px; + line-height: 1.9; +} +.pipeline-note code { + font-size: 9px; + color: #748572; +} +.controls-panel .panel-heading { + padding: 25px 23px 24px; +} +.controls-icon { + font-size: 25px; + font-weight: 400; + color: #89a58b; +} +.controls-panel form { + padding: 0 23px; +} +.control-label { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 11px; + font-weight: 600; + gap: 10px; +} +output { + font-family: monospace; + color: #518069; + background: #edf4ea; + border: 1px solid #dce8d6; + padding: 4px 7px; + border-radius: 4px; + font-size: 10px; +} +input[type="range"] { + appearance: none; + width: 100%; + height: 4px; + border-radius: 10px; + background: linear-gradient(to right, #59856c 50%, #e6ece2 50%); + margin: 21px 0 9px; + cursor: pointer; +} +input[type="range"]::-webkit-slider-thumb { + appearance: none; + width: 14px; + height: 14px; + border-radius: 50%; + background: #fff; + border: 3px solid #55846a; + box-shadow: 0 1px 4px #14382720; +} +input[type="range"]::-moz-range-thumb { + width: 9px; + height: 9px; + border-radius: 50%; + background: white; + border: 3px solid #55846a; +} +.range-scale { + display: flex; + justify-content: space-between; + font-family: monospace; + font-size: 8px; + color: #a2ac9e; +} +.control-help { + font-size: 10px; + color: #869384; + line-height: 1.8; + margin: 14px 0 0; +} +.control-divider { + height: 1px; + background: #edf1e8; + margin: 24px 0; +} +.run-buttons { + display: flex; + gap: 8px; + margin-top: 25px; +} +.button { + border: 1px solid transparent; + border-radius: 6px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 11px 14px; + font-size: 10px; + font-weight: 600; + transition: + background 0.15s, + transform 0.15s; + min-height: 37px; +} +.button:active { + transform: translateY(1px); +} +.button.primary { + color: #fff; + background: var(--ink); + border-color: var(--ink); + flex: 1; +} +.button.primary:hover { + background: #2b4941; +} +.button.primary span { + font-size: 8px; +} +.icon-button { + background: #f7f9f4; + color: #7c9277; + border-color: #e2e8dc; + padding: 10px 13px; + font-size: 18px; +} +.button.secondary { + background: #fff; + color: #5c735e; + border-color: #dce6d7; +} +.button.secondary:hover { + background: #f3f7ef; +} +.button.secondary span { + font-size: 14px; + font-weight: 400; +} +.run-status { + font-size: 9px; + color: #829b7b; + min-height: 18px; + line-height: 1.7; + padding: 13px 23px 0; +} +.upload-section, +.encryption-section { + margin-top: 18px; + border-top: 1px solid #edf1e8; + padding: 21px 23px; +} +.upload-section > .button, +.encryption-section > .button { + width: 100%; + margin-top: 15px; +} +.upload-section .control-help, +.encryption-section .control-help { + margin-top: 9px; +} +.text-button { + font-size: 9px; + font-weight: 500; + background: none; + color: #67866b; + border: 0; + padding: 10px 0; + display: inline-block; +} +.schema-details { + font-size: 9px; + color: #97a18f; + line-height: 1.8; + margin-top: 13px; +} +.schema-details summary { + cursor: pointer; +} +.schema-details p { + margin: 10px 0 0; +} +.schema-details code { + font-size: 9px; + color: #7c8c73; +} +.encryption-section { + margin-top: 0; + background: #fafcf7; +} +.encryption-section .run-status { + padding: 10px 0 0; +} +.metrics { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 0; + border: 1px solid var(--border); + background: white; + border-radius: 12px; + margin: 24px 0; + box-shadow: var(--shadow); +} +.metric { + padding: 23px 25px; + border-right: 1px solid var(--border); +} +.metric:last-child { + border: 0; +} +.metric strong { + font-family: "Space Grotesk", sans-serif; + font-size: 31px; + font-weight: 500; + letter-spacing: -1px; + display: block; + margin: 12px 0 7px; + line-height: 1; +} +.metric strong small { + font-size: 14px; + letter-spacing: 0; + margin-left: 3px; + color: #7f937f; +} +.metric > span:last-child { + font-size: 9px; + color: #929e8e; +} +.results-grid { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 20px; +} +.node-panel .panel-heading, +.diagnostics-panel .panel-heading { + padding-bottom: 20px; +} +.node-summary { + display: flex; + gap: 10px; + align-items: center; + padding: 0 25px 14px; + font-size: 10px; + color: #869580; +} +.node-summary strong { + font-weight: 550; + color: #57735b; +} +.class-pill { + padding: 4px 7px; + border-radius: 4px; + background: #eaf2e5; + color: #6a8560; + font-size: 9px; +} +.logit-bars { + padding: 0 25px 21px; + display: grid; + gap: 11px; +} +.logit-row { + display: grid; + grid-template-columns: 49px 1fr 63px; + align-items: center; + gap: 12px; + font-size: 10px; + color: #80907b; +} +.logit-track { + height: 7px; + background: #f2f6ed; + border-radius: 3px; + overflow: hidden; + position: relative; +} +.logit-zero { + position: absolute; + top: 0; + bottom: 0; + width: 1px; + background: #b8c5b0; + z-index: 1; +} +.logit-fill { + position: absolute; + height: 100%; + border-radius: 3px; + transition: + left 0.25s, + width 0.25s; +} +.logit-value { + font-family: monospace; + font-size: 10px; + text-align: right; + color: #688365; +} +.node-panel > .small-note { + padding: 0 25px 23px; +} +.diagnostics { + margin: 0; + padding: 0 25px; + display: grid; + gap: 16px; +} +.diagnostics > div { + display: flex; + justify-content: space-between; + gap: 15px; + font-size: 11px; + border-bottom: 1px solid #eff3e9; + padding-bottom: 10px; +} +.diagnostics dt { + color: #82947b; +} +.diagnostics dd { + font-family: monospace; + color: #57724f; + margin: 0; + font-size: 11px; +} +.diagnostics-panel > .small-note { + padding: 16px 25px; +} +.export-button { + margin: 0 25px 23px; +} +.ckks-results { + margin-top: 24px; + padding-bottom: 21px; +} +.ckks-diagnostics { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 18px; + padding: 0 25px 18px; +} +.ckks-stat { + font-size: 11px; + color: #6b7f62; +} +.ckks-stat strong { + display: block; + margin-top: 7px; + font-family: monospace; + color: var(--ink); +} +.ckks-results > .small-note { + padding: 0 25px; +} +.explanation { + display: flex; + align-items: flex-start; + gap: 80px; + padding: 58px 0 55px; + border-bottom: 1px solid var(--border); +} +.explanation > div:first-child { + flex: 0 0 34%; +} +.explanation h2 { + font-family: "Space Grotesk", sans-serif; + font-weight: 500; + font-size: 28px; + letter-spacing: -1px; + line-height: 1.3; + margin: 16px 0 0; +} +.explanation-copy { + max-width: 590px; +} +.explanation-copy p { + font-size: 12px; + line-height: 1.95; + color: #7e8d76; + margin: 0 0 14px; +} +.explanation-copy a { + font-size: 11px; + font-weight: 600; + color: #497256; + display: inline-flex; + gap: 8px; +} +footer { + max-width: 1440px; + margin: auto; + padding: 27px 5.5% 35px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 25px; + font-size: 9px; + color: #96a28b; +} +footer > div { + display: flex; + gap: 18px; + align-items: center; +} +footer strong { + font-family: "Space Grotesk", sans-serif; + font-size: 12px; + font-weight: 600; + color: #5b7756; +} +.footer-links { + gap: 22px; +} +.footer-links a { + color: #719367; +} +.message { + padding: 13px 17px; + border-radius: 7px; + font-size: 12px; + line-height: 1.6; + margin-bottom: 20px; +} +.message.error { + background: #fff1e8; + border: 1px solid #e7c4aa; + color: #8b593f; +} +.loading #graph { + opacity: 0.35; +} +@keyframes tile-in { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: none; + } +} +@media (min-width: 1440px) { + .hero { + padding-top: 66px; + } + .graph-stage { + height: 380px; + } +} +@media (max-width: 1100px) { + .hero { + gap: 30px; + } + .mode-card { + min-width: 260px; + max-width: 280px; + } + .workspace { + grid-template-columns: minmax(0, 1fr) 280px; + gap: 16px; + } + .panel-heading { + padding: 21px 20px 16px; + } + .panel-heading h2 { + font-size: 16px; + } + .graph-view-switch { + flex-direction: column; + } + .graph-meta { + padding-left: 20px; + } + .graph-bottom { + align-items: flex-start; + flex-direction: column; + gap: 8px; + padding: 14px 20px; + } + .pipeline { + padding: 0 20px 21px; + } + .pipeline-step code { + font-size: 8px; + } + .pipeline-step > span { + font-size: 7px; + } + .pipeline-arrow { + padding-right: 8px; + font-size: 14px; + } + .pipeline-note { + padding-left: 20px; + padding-right: 20px; + } + .metric { + padding: 21px 19px; + } + .metric .small-label { + font-size: 8px; + letter-spacing: 1px; + } + .metric strong { + font-size: 27px; + } + .explanation { + gap: 40px; + } + .footer-links > span { + display: none; + } +} +@media (max-width: 850px) { + .topbar { + height: 74px; + padding: 0 5%; + } + main { + padding: 0 5%; + } + .brand-lab, + .brand-divider { + display: none; + } + nav { + gap: 20px; + } + nav a { + font-size: 11px; + } + .hero { + padding: 39px 0 30px; + gap: 25px; + } + .hero-copy { + max-width: 65%; + } + .hero-copy > p { + font-size: 12px; + } + .mode-card { + min-width: 225px; + max-width: 240px; + padding: 17px; + } + .mode-card strong { + font-size: 11px; + } + .mode-card p { + font-size: 10px; + } + .mode-footer { + font-size: 8px; + } + h1 { + font-size: 36px; + letter-spacing: -1.4px; + } + .workspace { + grid-template-columns: minmax(0, 1fr); + } + .controls-panel { + display: grid; + grid-template-columns: 1fr 1fr; + overflow: visible; + } + .controls-panel .panel-heading { + grid-column: 1/-1; + padding-bottom: 20px; + } + .controls-panel form { + grid-row: 2/5; + border-right: 1px solid var(--border); + padding-bottom: 25px; + } + .controls-panel > .run-status { + grid-column: 1; + grid-row: 5; + padding-bottom: 20px; + } + .upload-section { + grid-column: 2; + grid-row: 2/3; + margin: 0; + border: 0; + padding-top: 0; + } + .encryption-section { + grid-column: 2; + grid-row: 3/6; + margin: 0; + background: transparent; + padding-top: 18px; + } + .control-divider { + margin: 20px 0; + } + .graph-view-switch { + flex-direction: row; + } + .graph-stage { + height: 350px; + } + .graph-bottom { + flex-direction: row; + align-items: center; + } + .pipeline-step code { + font-size: 11px; + } + .pipeline-step > span { + font-size: 9px; + } + .pipeline-note { + font-size: 10px; + } + .metric { + padding: 19px 15px; + } + .metric strong { + font-size: 24px; + } + .metric > span:last-child { + font-size: 8px; + } + .results-grid { + gap: 15px; + } + .panel-heading h2 { + font-size: 17px; + } + .explanation { + padding: 40px 0; + gap: 30px; + } + .explanation h2 { + font-size: 25px; + } + footer { + padding: 24px 5%; + } + .footer-links { + display: none; + } +} +@media (max-width: 600px) { + .topbar { + height: 66px; + } + .brand { + font-size: 20px; + } + .brand svg { + width: 28px; + } + nav { + gap: 16px; + } + nav > a:first-child { + display: none; + } + .local-badge { + font-size: 9px; + padding: 6px 9px; + } + .hero { + display: block; + padding-top: 32px; + } + .hero-copy { + max-width: none; + } + h1 { + font-size: 35px; + line-height: 1.15; + } + .eyebrow { + font-size: 8px; + letter-spacing: 1.5px; + margin-bottom: 15px; + } + .hero-copy > p { + font-size: 12px; + line-height: 1.8; + } + .mode-card { + max-width: none; + margin-top: 25px; + padding: 15px 18px; + } + .mode-card .small-label { + font-size: 8px; + } + .mode-card strong { + font-size: 12px; + margin: 10px 0 8px; + } + .mode-card p { + font-size: 10px; + } + .mode-footer { + display: none; + } + .panel-heading { + padding: 20px 17px 14px; + } + .panel-heading h2 { + font-size: 16px; + } + .graph-view-switch { + flex-direction: column; + } + .view-button { + font-size: 8px; + padding: 5px 7px; + } + .graph-meta { + padding: 0 17px 14px; + gap: 8px; + font-size: 9px; + } + .graph-stage { + height: 260px; + } + .graph-bottom { + padding: 13px 17px; + align-items: flex-start; + flex-direction: column; + } + .graph-bottom > .small-note { + font-size: 8px; + } + .legend-item { + font-size: 8px; + } + .pipeline { + padding: 0 17px 20px; + gap: 7px; + } + .pipeline-step { + gap: 8px; + } + .pipeline-step code { + font-size: 7px; + letter-spacing: -0.2px; + } + .pipeline-step > span { + font-size: 7px; + line-height: 1.5; + } + .pipeline-step strong { + font-size: 10px; + } + .pipeline-arrow { + font-size: 12px; + padding: 0; + } + .bank-tiles { + gap: 2px; + flex-wrap: wrap; + } + .bank-tile { + font-size: 7px; + padding: 2px 4px; + } + .pipeline-note { + font-size: 8px; + padding: 12px 17px 14px; + } + .pipeline-note code { + font-size: 8px; + } + .controls-panel { + display: block; + } + .controls-panel form { + border: 0; + padding: 0 21px; + } + .controls-panel .panel-heading { + padding: 21px; + } + .controls-panel > .run-status { + padding: 13px 21px 17px; + } + .control-divider { + margin: 22px 0; + } + .upload-section, + .encryption-section { + padding: 20px 21px; + border-top: 1px solid #edf1e8; + } + .encryption-section { + background: #fafcf7; + } + .metrics { + grid-template-columns: repeat(2, 1fr); + margin: 20px 0; + } + .metric { + padding: 21px; + } + .metric:nth-child(2) { + border-right: 0; + } + .metric:nth-child(-n + 2) { + border-bottom: 1px solid var(--border); + } + .metric strong { + font-size: 29px; + } + .metric .small-label { + font-size: 8px; + } + .metric > span:last-child { + font-size: 9px; + } + .results-grid { + grid-template-columns: 1fr; + gap: 20px; + } + .explanation { + display: block; + padding: 36px 0; + } + .explanation h2 { + font-size: 26px; + } + .explanation-copy { + margin-top: 23px; + } + .explanation-copy p { + font-size: 11px; + } + footer > div { + flex-wrap: wrap; + gap: 8px; + } + footer strong { + width: 100%; + } + footer { + padding-bottom: 27px; + } + .ckks-diagnostics { + grid-template-columns: 1fr 1fr; + } + .node-panel .panel-heading, + .diagnostics-panel .panel-heading { + padding: 21px; + } + .logit-bars, + .diagnostics { + padding-left: 21px; + padding-right: 21px; + } + .node-summary { + padding-left: 21px; + padding-right: 21px; + } +} +@media (prefers-reduced-motion: reduce) { + * { + animation: none !important; + transition: none !important; + } +} +.explanation-copy .method-credit { + margin-top: 17px; + font-size: 10px; + line-height: 1.8; + color: #8a9882; +} +.explanation-copy .method-credit a { + display: inline; + font-size: 10px; + font-weight: 500; +} diff --git a/tests/browser/krylov.spec.js b/tests/browser/krylov.spec.js new file mode 100644 index 0000000..05d2d7d --- /dev/null +++ b/tests/browser/krylov.spec.js @@ -0,0 +1,117 @@ +import { test, expect } from '@playwright/test'; +import { readFile } from 'node:fs/promises'; + +const demo = JSON.parse(await readFile(new URL('../../site/data/demo.json', import.meta.url), 'utf8')); + +async function openLab(page) { + await page.goto('./'); + await expect(page.locator('#run-status')).toContainText('Complete'); + await expect(page.locator('#page-error')).toBeHidden(); +} + +async function downloadResult(page) { + const waiting = page.waitForEvent('download'); + await page.locator('#export').click(); + const download = await waiting; + return JSON.parse(await readFile(await download.path(), 'utf8')); +} + +test('project-path assets load and controls change an inspectable graph computation', async ({ page }) => { + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + await openLab(page); + await expect(page.locator('#graph .graph-node')).toHaveCount(24); + await expect(page.locator('#agreement')).toHaveText('100.0%'); + await expect(page.locator('#bank-count')).toHaveText('216'); + await page.locator('#graph .graph-node').nth(6).click(); + await expect(page.locator('#node-id')).toHaveText('NODE 6'); + await page.getByRole('button', { name: 'Reference labels', exact: true }).click(); + await expect(page.locator('#show-labels')).toHaveAttribute('aria-pressed', 'true'); + await page.locator('#degree').focus(); + await page.locator('#degree').press('End'); + await page.getByRole('button', { name: 'Run experiment' }).click(); + await expect(page.locator('#run-status')).toContainText('K = 4'); + await expect(page.locator('#bank-count')).toHaveText('360'); + await page.getByRole('button', { name: 'Reset controls' }).click(); + await expect(page.locator('#run-status')).toContainText('K = 2'); + await expect(page.locator('#bank-count')).toHaveText('216'); + expect(errors).toEqual([]); +}); + +test('invalid uploads preserve the current graph; valid data stays local and renders as text', async ({ page }) => { + await openLab(page); + const writes = []; + page.on('request', (request) => { if (request.method() !== 'GET') writes.push(request.url()); }); + await page.locator('#upload').setInputFiles({ name: 'broken.json', mimeType: 'application/json', buffer: Buffer.from('{broken') }); + await expect(page.getByRole('alert')).toContainText('not valid JSON'); + await expect(page.locator('#graph .graph-node')).toHaveCount(24); + const custom = structuredClone(demo); + custom.name = 'Local graph '; + custom.nodes[0].label = ''; + custom.model.theta = custom.model.theta.slice(0, 1); + custom.default_degree = 0; + await page.locator('#upload').setInputFiles({ name: 'custom.json', mimeType: 'application/json', buffer: Buffer.from(JSON.stringify(custom)) }); + await expect(page.locator('#fixture-kind')).toHaveText('LOCAL JSON'); + await expect(page.locator('#run-status')).toContainText('K = 0'); + await expect(page.locator('#bank-count')).toHaveText('72'); + await expect(page.locator('#fixture-name')).toHaveText(custom.name); + await expect(page.locator('#fixture-name img')).toHaveCount(0); + expect(await page.evaluate(() => window.fixtureInjected)).toBeUndefined(); + await expect(page.locator('#run-ckks')).toBeDisabled(); + expect(writes).toEqual([]); + await page.getByRole('button', { name: 'Return to bundled example' }).click(); + await expect(page.locator('#run-status')).toContainText('K = 2'); + await expect(page.locator('#run-ckks')).toBeEnabled(); +}); + +test('export identifies plaintext execution and reproduces the selected controls', async ({ page }) => { + await openLab(page); + await page.locator('#perturbation').focus(); + await page.locator('#perturbation').press('ArrowRight'); + await page.getByRole('button', { name: 'Run experiment' }).click(); + await expect(page.locator('#run-status')).toContainText('Β±0.01'); + const result = await downloadResult(page); + expect(result.degree).toBe(2); + expect(result.perturbation).toBe(0.01); + expect(result.diagnostics.prediction_agreement).toBe(1); + expect(result.diagnostics.max_absolute_logit_error).toBeLessThan(1e-10); + expect(result.optional_ckks).toBeNull(); + expect(JSON.stringify(result)).toContain('plaintext'); +}); + +test('real browser CKKS runs with a secret-free evaluator and current model controls', async ({ page }) => { + const errors = []; + page.on('pageerror', (error) => errors.push(error.message)); + await openLab(page); + await page.locator('#degree').focus(); + await page.locator('#degree').press('End'); + // Deliberately skip the plaintext Run button: encrypted execution must sync it. + await page.getByRole('button', { name: 'Run real CKKS' }).click(); + await expect(page.locator('#ckks-status')).toContainText('Complete', { timeout: 60_000 }); + await expect(page.locator('#ckks-results')).toBeVisible(); + await expect(page.locator('#ckks-diagnostics')).toContainText('Absent from context'); + const result = await downloadResult(page); + expect(result.degree).toBe(4); + expect(result.optional_ckks.degree).toBe(4); + expect(result.optional_ckks.publicContextHasSecretKey).toBe(false); + expect(result.optional_ckks.predictionAgreement).toBe(1); + expect(result.optional_ckks.maxAbsoluteError).toBeLessThan(1e-3); + expect(result.optional_ckks.ciphertextBytes).toBeGreaterThan(1000); + expect(result.optional_ckks.timings.encryptionMs).toBeGreaterThan(0); + expect(result.optional_ckks.timings.evaluationMs).toBeGreaterThan(0); + expect(errors).toEqual([]); + await page.locator('#degree').focus(); + await page.locator('#degree').press('Home'); + await expect(page.locator('#ckks-results')).toBeHidden(); + const afterChange = await downloadResult(page); + expect(afterChange.optional_ckks).toBeNull(); +}); + +test('mobile layout remains usable without horizontal page overflow', async ({ page }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await openLab(page); + await expect(page.getByRole('heading', { level: 1 })).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true); + await page.getByRole('button', { name: 'Run experiment' }).click(); + await expect(page.locator('#run-status')).toContainText('Complete'); +}); diff --git a/tests/test_krylov.py b/tests/test_krylov.py new file mode 100644 index 0000000..22cac65 --- /dev/null +++ b/tests/test_krylov.py @@ -0,0 +1,261 @@ +"""Check independent polynomial oracles and the real HE serialization boundary.""" + +import copy +import json +from pathlib import Path +import subprocess +import sys + +import numpy as np +import pytest + +from applications.krylov import ( + compile_chebyshev_blocks, evaluate_affine, evaluate_model, + evaluate_polynomial, evaluate_readout, normalize_adjacency, +) +from applications.krylov.demo import fixture_arrays, make_demo + + +def direct_blocks(shift, features): + """Independent closed-form polynomials, not the recurrence under test.""" + identity = np.eye(len(shift)) + square = shift @ shift + cube = square @ shift + fourth = square @ square + operators = [identity, shift, 2 * square - identity, + 4 * cube - 3 * shift, 8 * fourth - 8 * square + identity] + return np.stack([operator @ features for operator in operators]) + + +def direct_logits(blocks, model, degree): + theta = np.asarray(model["theta"]) + affine = np.broadcast_to(np.asarray(model["bias"]), (blocks.shape[1], theta.shape[2])).copy() + for k in range(degree + 1): + affine += blocks[k] @ theta[k] + hidden = sum(coefficient * affine ** power + for power, coefficient in enumerate(model["activation_coefficients"])) + return hidden @ np.asarray(model["output_weights"]) + np.asarray(model["output_bias"]) + + +def test_normalization_includes_isolated_node_and_symmetric_degree_weights(): + adjacency = np.array([[0, 2, 0], [2, 0, 0], [0, 0, 0]]) + expected = np.array([[1 / 3, 2 / 3, 0], [2 / 3, 1 / 3, 0], [0, 0, 1]]) + np.testing.assert_allclose(normalize_adjacency(adjacency), expected) + + +@pytest.mark.parametrize("degree", range(5)) +def test_chebyshev_recurrence_matches_closed_form_polynomials(degree): + rng = np.random.default_rng(923) + adjacency = np.array([[0, 1, 0, 1], [1, 0, 1, 0], [0, 1, 0, 0], [1, 0, 0, 0]]) + shift = normalize_adjacency(adjacency) + features = rng.normal(size=(4, 3)) + np.testing.assert_allclose(compile_chebyshev_blocks(shift, features, degree), + direct_blocks(shift, features)[:degree + 1], atol=1e-12) + + +def test_affine_polynomial_readout_matches_direct_oracle(): + rng = np.random.default_rng(413) + blocks = rng.uniform(-0.3, 0.3, size=(5, 7, 3)) + model = { + "theta": rng.normal(size=(5, 3, 4)), "bias": rng.normal(size=4), + "activation_coefficients": [0.2, -0.4, 0.3, 0.1], + "output_weights": rng.normal(size=(4, 2)), "output_bias": [0.13, -0.21], + } + for degree in range(5): + np.testing.assert_allclose(evaluate_model(blocks, model, degree), + direct_logits(blocks, model, degree), atol=1e-12) + + +@pytest.mark.parametrize("degree", [-1, 5, 1.5, True]) +def test_invalid_degrees_are_rejected(degree): + with pytest.raises(ValueError, match="degree"): + compile_chebyshev_blocks(np.eye(2), np.ones((2, 1)), degree) + + +@pytest.mark.parametrize("adjacency", [ + [[0, 1], [0, 0]], [[0, -1], [-1, 0]], [[0, np.inf], [np.inf, 0]], + [[0, np.nan], [np.nan, 0]], [[0, 1, 2], [1, 0, 2]], [], +]) +def test_invalid_adjacency_is_rejected(adjacency): + with pytest.raises(ValueError): + normalize_adjacency(adjacency) + + +def test_invalid_shapes_finiteness_and_overflow_are_rejected(): + with pytest.raises(ValueError): + compile_chebyshev_blocks(np.eye(3), np.ones((2, 1)), 2) + with pytest.raises(ValueError): + evaluate_affine(np.ones((2, 3, 1)), np.ones((2, 2, 1)), [0], 1) + with pytest.raises(ValueError): + evaluate_affine(np.ones((1, 3, 1)), np.ones((1, 1, 1)), [0], 1) + with pytest.raises(ValueError): + evaluate_polynomial(np.ones((3, 1)), [0, np.nan]) + with pytest.raises(ValueError): + evaluate_polynomial(np.full((3, 1), 1e300), [0, 0, 1]) + with pytest.raises(ValueError): + evaluate_readout(np.ones((3, 2)), np.ones((3, 2)), [0, 0]) + + +@pytest.mark.parametrize("features", [[["0.1"]], [[True]], [[0.1 + 0.2j]]]) +def test_real_numerical_arrays_do_not_coerce_strings_booleans_or_complex(features): + with pytest.raises(ValueError): + compile_chebyshev_blocks(np.eye(1), features, 0) + + +@pytest.mark.parametrize("defect", ["duplicate", "self_loop", "out_of_bounds", "float_labels", "feature_width"]) +def test_demo_fixture_rejects_invalid_graph_semantics(defect): + fixture = make_demo() + if defect == "duplicate": + fixture["edges"].append(fixture["edges"][0]) + elif defect == "self_loop": + fixture["edges"].append([0, 0]) + elif defect == "out_of_bounds": + fixture["edges"].append([0, len(fixture["nodes"])]) + elif defect == "float_labels": + fixture["labels"] = [float(label) for label in fixture["labels"]] + else: + fixture["features"][0] = [0] + with pytest.raises(ValueError): + fixture_arrays(fixture) + + +@pytest.mark.parametrize("fixture", [[], None, {"schema_version": 1, "nodes": [None]}, + {"schema_version": 1, "nodes": [{}] * 121}]) +def test_malformed_or_oversized_fixture_is_rejected_before_dense_allocation(fixture): + with pytest.raises(ValueError): + fixture_arrays(fixture) + + +@pytest.mark.parametrize("degree", [0, 2, 4]) +def test_cli_and_exported_browser_artifact_match_independent_oracle(tmp_path, degree): + output = tmp_path / "result.json" + artifact_path = tmp_path / "fixture.json" + run = subprocess.run([ + sys.executable, "-m", "applications.krylov", "--backend", "plain", "--degree", str(degree), + "--output", str(output), "--export-demo", str(artifact_path), + ], cwd=Path(__file__).resolve().parents[1], capture_output=True, text=True) + assert run.returncode == 0, run.stderr + result = json.loads(output.read_text()) + fixture = json.loads(artifact_path.read_text()) + adjacency, features, labels = fixture_arrays(fixture) + looped = adjacency + np.eye(len(labels)) + inverse_degrees = 1 / np.sqrt(looped.sum(axis=1)) + independent_shift = inverse_degrees[:, None] * looped * inverse_degrees[None, :] + reference = direct_logits(direct_blocks(independent_shift, features), fixture["model"], degree) + np.testing.assert_allclose(result["logits"], reference, atol=1e-12) + assert result["degree"] == degree + assert result["measurement_kind"] == "executed plaintext" + assert result["accuracy_scope"] == "Illustrative all-node/training accuracy; no held-out split" + assert len(fixture["model"]["theta"]) == 5 + assert fixture["provenance"]["training_degree"] == 4 + assert result["predictions"] == reference.argmax(axis=1).tolist() + + +@pytest.mark.parametrize("invalid_input", [b"[]", b"{bad json}", b"\xff\xfe\x00"]) +def test_cli_bad_fixture_exits_cleanly_without_writing_result(tmp_path, invalid_input): + fixture = tmp_path / "invalid.json" + output = tmp_path / "result.json" + fixture.write_bytes(invalid_input) + run = subprocess.run([ + sys.executable, "-m", "applications.krylov", "--fixture", str(fixture), "--output", str(output), + ], cwd=Path(__file__).resolve().parents[1], capture_output=True, text=True) + assert run.returncode == 2 + assert "Krylov run failed" in run.stderr + assert "Traceback" not in run.stderr + assert not output.exists() + + +def test_cli_import_uses_fixture_degree_and_rejects_unavailable_degree(tmp_path): + fixture = make_demo() + fixture["default_degree"] = 1 + fixture["model"]["theta"] = fixture["model"]["theta"][:2] + fixture_path = tmp_path / "fixture.json" + fixture_path.write_text(json.dumps(fixture)) + output = tmp_path / "result.json" + command = [sys.executable, "-m", "applications.krylov", "--fixture", str(fixture_path), "--output", str(output)] + cwd = Path(__file__).resolve().parents[1] + run = subprocess.run(command, cwd=cwd, capture_output=True, text=True) + assert run.returncode == 0, run.stderr + assert json.loads(output.read_text())["degree"] == 1 + unavailable_output = tmp_path / "unavailable.json" + run = subprocess.run(command[:-1] + [str(unavailable_output), "--degree", "4"], + cwd=cwd, capture_output=True, text=True) + assert run.returncode == 2 + assert "exceeds the fixture theta bank" in run.stderr + assert not unavailable_output.exists() + + +@pytest.fixture(scope="module") +def encrypted_example(): + ts = pytest.importorskip("tenseal") + from applications.krylov.ckks import client_encrypt_blocks + + blocks = np.array([[[0.3, -0.2], [0.1, 0.4], [-0.2, 0.2]], + [[0.1, 0.2], [-0.1, 0.3], [0.4, -0.2]]]) + model = { + "theta": [[[0.2, -0.1], [0.3, 0.4]], [[-0.15, 0.2], [0.1, -0.2]]], + "bias": [0.1, -0.2], "activation_coefficients": [0, 0, 1], + "output_weights": [[0.3, -0.4], [-0.2, 0.6]], "output_bias": [0.05, -0.03], + } + session, request, _ = client_encrypt_blocks(blocks, 1) + return ts, session, request, model, blocks + + +def test_real_ckks_public_server_boundary_and_same_model_fidelity(encrypted_example): + ts, session, request, model, blocks = encrypted_example + from applications.krylov.ckks import evaluate_server + + public_context = ts.context_from(request.public_context, n_threads=2) + assert public_context.is_public() + assert not public_context.has_secret_key() + assert session.context.has_secret_key() + assert all(isinstance(column, bytes) for columns in request.block_columns for column in columns) + response, details = evaluate_server(request, model) + assert all(isinstance(column, bytes) for column in response) + with pytest.raises((ValueError, RuntimeError)): + ts.ckks_vector_from(public_context, response[0]).decrypt() + np.testing.assert_allclose(session.decrypt(response), direct_logits(blocks, model, 1), atol=1e-4, rtol=0) + assert details["server_context_has_secret_key"] is False + assert details["operations"]["rotations"] == 0 + assert request.ciphertext_bytes > 0 + + +def test_server_rejects_secret_bearing_context(encrypted_example): + _, session, request, model, _ = encrypted_example + from dataclasses import replace + from applications.krylov.ckks import evaluate_server + + unsafe_context = session.context.serialize(save_secret_key=True, save_galois_keys=False) + with pytest.raises(ValueError, match="no secret key"): + evaluate_server(replace(request, public_context=unsafe_context), model) + + +def test_ckks_rejects_unsupported_activation_without_plain_fallback(encrypted_example): + _, _, request, model, _ = encrypted_example + from applications.krylov.ckks import evaluate_server + + unsupported = copy.deepcopy(model) + unsupported["activation_coefficients"] = [0, 1] + with pytest.raises(ValueError, match="square"): + evaluate_server(request, unsupported) + + +@pytest.mark.parametrize("zero_column", ["affine", "readout", "both"]) +def test_real_ckks_all_zero_columns_do_not_create_transparent_ciphertexts(encrypted_example, zero_column): + _, session, request, model, blocks = encrypted_example + from applications.krylov.ckks import evaluate_server + + model = copy.deepcopy(model) + if zero_column in ("affine", "both"): + theta = np.asarray(model["theta"]) + theta[:, :, 0] = 0 + model["theta"] = theta.tolist() + model["bias"][0] = 0 + if zero_column in ("readout", "both"): + weights = np.asarray(model["output_weights"]) + weights[:, 0] = 0 + model["output_weights"] = weights.tolist() + model["output_bias"][0] = 0 + response, details = evaluate_server(request, model) + np.testing.assert_allclose(session.decrypt(response), direct_logits(blocks, model, 1), atol=1e-4, rtol=0) + assert details["operations"]["public_key_encryptions_of_constant_columns"] >= 1 diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000..5224dd8 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,17 @@ +import { defineConfig } from 'vite'; +import { fileURLToPath } from 'node:url'; + +export default defineConfig({ + root: 'site', + base: './', + resolve: { + alias: { + 'node-seal-wasm?url': `${fileURLToPath(new URL('./node_modules/node-seal/dist/seal_throws.wasm', import.meta.url))}?url`, + 'node-seal-wasm': fileURLToPath(new URL('./node_modules/node-seal/dist/seal_throws.wasm', import.meta.url)), + }, + }, + build: { + outDir: '../dist', + emptyOutDir: true, + }, +}); From ede4c2667138fadc47055ca89a4564704ca3ad42 Mon Sep 17 00:00:00 2001 From: mohmd Date: Sat, 10 Oct 2026 13:28:53 +0000 Subject: [PATCH 2/2] docs: record verified Pages deployment and update requirements --- docs/krylov.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/krylov.md b/docs/krylov.md index 33bf563..ba1b1d2 100644 --- a/docs/krylov.md +++ b/docs/krylov.md @@ -95,3 +95,11 @@ The demo CKKS tuple is polynomial modulus degree 16,384, coefficient-modulus bit The new Krylov/browser checks are in `.github/workflows/krylov.yml`. The legacy simulator precision defect documented in the [research archive](research/2026-10-10/README.md) remains independent of this application. The initial tested site is published from a generated `gh-pages` branch at [aliaslany.github.io/PrivnetAI](https://aliaslany.github.io/PrivnetAI/). For subsequent updates, `.github/workflows/pages.yml` builds and tests the site on `main`, uploads a Pages artifact and deploys it using GitHub's supported Pages actions. It also supports manual dispatch. Source changes are reviewed through a feature PR; the initial demo can be tested before that PR is merged. Pages settings must select GitHub Actions for this ongoing deployment workflow. + +The initial branch publication was verified live, including real browser CKKS and result export. Repository setup for subsequent `main` deployments is: + +1. In [Pages settings](https://github.com/aliaslany/PrivnetAI/settings/pages), select **GitHub Actions** as the source. +2. In [environment settings](https://github.com/aliaslany/PrivnetAI/settings/environments), ensure the `github-pages` environment permits deployment from **main**. The initial branch publication permits `gh-pages`; keep it if needed. +3. Merge the feature PR, or manually dispatch **Publish PrivnetAI Pages** after merging. + +The integration's API credential can publish repository branches but GitHub returned HTTP 403 when asked to change Pages settings. These settings steps do not affect using the already published demo.