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..ba1b1d2
--- /dev/null
+++ b/docs/krylov.md
@@ -0,0 +1,105 @@
+# 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.
+
+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.
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
+
+
+
+
+
+
+
+
+
+
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.
+
+
+ CURRENT WORKSPACE
+ Local plaintext demonstration
+ The default experiment does not encrypt. Your graph stays in this browser; uploaded JSON is never sent to a server.
+
+
+
+
+
+
+
+
+
+
+
01 / THE GRAPH
A graph you can inspect
+
+ Predictions
+ Reference labels
+
+
+ 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
Normalize S = Dβ»Β½(A + I)Dβ»Β½Undirected graph Β· one self loop
+
β
+
2
Compile
Bβ = X Β· Bβ = SX
+
β
+
3
Evaluate p(Ξ£ 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.
+
+
+
+
+ EXPERIMENT CONTROLS
Make it your own β
+
+ Preparing the bundled exampleβ¦
+
+
Bring your own graph JSON
+
Explore the same pipeline with a small local fixture.
+
β Open local JSON
+
+
Return to bundled example
+
Fixture format & limits Schema v1: nodes with consecutive integer ids 0β¦Nβ1 and display labels, index-pair edges, numeric features, class-index labels, model, and default_degree. Activation coefficients are ascending. Maximum 120 nodes, 32 features/hidden channels, 16 classes, degree 4, and 256 KB.
Download the example JSON β
+
+
+
Optional real CKKS PROTOTYPE
+
A separate encrypted run of the bundled feature-bank readout. Client and evaluator run in one browser; evaluator context excludes the secret key.
+
β Run real CKKS
+
Available after the example loads.
+
+
+
+
+
+ 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.
+ β Export this result
+
+
+
+
+ OPTIONAL ENCRYPTED READOUT
Real CKKS Β· local prototype ENCRYPTED FEATURE BANK
+
+
+ Graph compilation is plaintext at the client. This prototype is not encrypted training, private-topology evaluation, remote key isolation, or a production security guarantee.
+
+
+
+ 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,
+ },
+});