Skip to content

Commit fb67740

Browse files
committed
Docs: Tutorials extended
1 parent f194cdb commit fb67740

8 files changed

Lines changed: 339 additions & 0 deletions

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,8 @@ def mul_local(mpc, a: int, b: int, c: int):
9090
mul_local(7, 13, 19)
9191
```
9292

93+
> **Note:** When working with many local runs, the socket files (`sock.*`)---needed for local communication---may collude in-between the runs and cause connection issues. Make sure to delete the stale files in that case `rm sock.*`.
94+
9395
```bash
9496
sequre local_run.codon
9597
```
@@ -111,7 +113,9 @@ mpc = mpc()
111113
a = Stensor.enc(mpc, 7)
112114
b = Stensor.enc(mpc, 13)
113115
c = Stensor.enc(mpc, 19)
116+
114117
print(f"CP{mpc.pid}:\t{muls(mpc, a, b, c).reveal(mpc)}")
118+
mpc.done() # Wait for all parties to finish and then close the sockets
115119
```
116120

117121
```bash

docs/index.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,5 +68,8 @@ Sequre ships with production-grade implementations of:
6868

6969
- **[Quickstart →](getting-started/quickstart.md)** — Install and run Sequre in minutes.
7070
- **[Basic MPC Tutorial →](tutorials/basic-mpc.md)** — Understand additive secret sharing with `Sharetensor`.
71+
- **[Secure Branching Without if →](tutorials/secure-branching-without-if.md)** — Learn mask-based selection patterns for private control flow.
72+
- **[One Algorithm, Many Secure Types →](tutorials/one-algorithm-many-secure-types.md)** — Reuse the same algorithm across ndarray, Sharetensor, and multiparty encrypted types.
7173
- **[Transitioning to MHE →](tutorials/transition-mhe.md)** — Move from secret sharing to homomorphic encryption with Shechi.
74+
- **[Dropping Down the Stack →](tutorials/dropping-down-the-stack.md)** — Start high-level, then descend through switching, MHE internals, and Lattiseq.
7275
- **[API Reference →](api/index.md)** — Complete reference for all public types and modules.

docs/tutorials/basic-mpc.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ These are more expensive than arithmetic but fully supported.
121121

122122
## Next steps
123123

124+
- **[Secure Branching Without if](secure-branching-without-if.md)** — Build private min/max and branch bypass logic safely.
124125
- **[Transitioning to MHE](transition-mhe.md)** — When to prefer homomorphic encryption over secret sharing.
125126
- **[Sharetensor API](../api/sharetensor.md)** — Complete reference.
126127
- **[MPCEnv API](../api/mpcenv.md)** — Sub-modules and methods.
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# Dropping Down the Stack
2+
3+
Sequre is usually written at the top level — `@sequre` functions and tensor expressions. However, it also allows for a finer control: maybe a particular subroutine is better in MPC than HE, specific matmul strategy needs to be picked, or even the raw CKKS procedures invoked.
4+
5+
This page traces two real examples from high-level code down through the layers, showing where each boundary is and when it makes sense to cross it.
6+
7+
For the overall architecture (Layers 1–4), see the [Home page](../index.md).
8+
9+
## Example A: PCA and `via_mpc`
10+
11+
### Starting point: high-level PCA
12+
13+
In [stdlib/sequre/stdlib/learn/pca.codon](../../stdlib/sequre/stdlib/learn/pca.codon), the randomized PCA routines are written as straightforward linear algebra — matrix products, orthonormalization, eigendecomposition. Most of the algorithm stays at the top level: `@` for matrix multiply, `.T` for transpose, slicing for submatrix extraction.
14+
15+
But two operations can't be done (or are very expensive) in pure HE: orthonormalization and eigendecomposition. So PCA drops into MPC for those, using `via_mpc`:
16+
17+
```python
18+
# Inside random_pca_without_projection:
19+
# Step 4 — orthonormalize needs comparisons, which HE can't do natively.
20+
r_mpp = (p_mpa @ data_mpp.T).via_mpc(
21+
lambda stensor: orthonormalize(mpc, stensor))
22+
23+
# Step 6 — eigendecomposition also needs MPC.
24+
u_mpa = z_cov_mpa.via_mpc(
25+
lambda stensor: eigen_decomp(mpc, stensor)[0][:top_components_count])
26+
```
27+
28+
What happens when `.via_mpc(fn)` is called on an encrypted type like `MPA` or `MPP`:
29+
30+
1. The encrypted data is collectively decrypted into additive shares (E2S protocol).
31+
2. The lambda runs on the resulting `Sharetensor` — using Beaver-triple MPC.
32+
3. The result is re-encrypted back into the original form (S2E protocol).
33+
34+
So `via_mpc` is the boundary between "stay in HE" and "temporarily drop into MPC for this one operation." Everything around it — the `@` products, the slicing — stays in the HE world.
35+
36+
### One layer down: type conversions
37+
38+
The E2S and S2E steps are implemented in [stdlib/sequre/types/internal.codon](../../stdlib/sequre/types/internal.codon):
39+
40+
- `Ciphertensor.to_sharetensor(mpc, ...)` — decrypts via the E2S protocol.
41+
- `Sharetensor.to_ciphertensor(mpc, ...)` — re-encrypts via S2E.
42+
- `to_mpp`, `to_mpa`, `to_mpu` — handle the multiparty wrappers.
43+
44+
These methods delegate to `mpc.mhe.ciphervector_to_additive_share_vector` and `mpc.mhe.additive_share_vector_to_ciphervector` — the core MHE conversion routines in [stdlib/sequre/mpc/mhe.codon](../../stdlib/sequre/mpc/mhe.codon), documented in [Core MHE Module](../deep-dive-shechi/core-mhe.md).
45+
46+
### The deepest layer: Lattiseq protocols
47+
48+
Those MHE conversion methods ultimately call into the Lattiseq distributed CKKS protocols — `E2SProtocol`, `S2EProtocol`, `RefreshProtocol` — which operate on ring polynomials, NTT transforms, and secret key shards. For details, see [Lattiseq Overview](../deep-dive-lattiseq/overview.md) and [CKKS Operations](../deep-dive-lattiseq/ckks-operations.md).
49+
50+
This layer rarely needs to be touched directly. But when implementing a new collective protocol or debugging bootstrap failures, this is where things end up.
51+
52+
## Example B: how `@` picks a matmul strategy
53+
54+
### Starting point: linear regression
55+
56+
In [stdlib/sequre/stdlib/learn/lin_reg.codon](../../stdlib/sequre/stdlib/learn/lin_reg.codon), the gradient descent loop does:
57+
58+
```python
59+
cov = X_tilde.T @ X_tilde # n x n
60+
ref = X_tilde.T @ y # n x 1
61+
```
62+
63+
When `X_tilde` is a `Sharetensor`, this is a standard Beaver-triple matmul — one implementation, done. But when `X_tilde` is backed by a `Ciphertensor` (HE), the `@` operator has to make a choice.
64+
65+
### One layer down: the cost selector
66+
67+
The key function is `_switch_matmul_by_cost` in [stdlib/sequre/types/ciphertensor.codon](../../stdlib/sequre/types/ciphertensor.codon). When a `Ciphertensor` is multiplied by a plaintext `ndarray`, it estimates the cost of four strategies:
68+
69+
```python
70+
costs = (Ciphertensor._get_matmul_via_mpc_cost(self, other), # decrypt, MPC matmul, re-encrypt
71+
Ciphertensor._get_matmul_v1_cost(self, other), # M1: column-packed HE
72+
Ciphertensor._get_matmul_v2_cost(self, other), # M2: row-packed HE
73+
Ciphertensor._get_matmul_v3_cost(self, other)) # M3: diagonal-packed HE
74+
75+
if not mpc.default_allow_mpc_switch:
76+
costs = (inf, *costs[1:]) # disable MPC path unless opted in
77+
```
78+
79+
Then it picks the cheapest:
80+
81+
```python
82+
match argmin(costs):
83+
case 0: return self.via_mpc(mpc, lambda stensor: secure_operator.matmul(mpc, stensor, other), ...)
84+
case 1: return self._matmul_v1(mpc, other_cipher, debug)
85+
case 2: return self._matmul_v2(mpc, ..., debug)
86+
case 3: return self._matmul_v3(mpc, ..., debug)
87+
```
88+
89+
So the same `@` in the algorithm code can end up as a completely different computation path depending on tensor shapes and whether `mpc.allow_mpc_switch()` is active. `DEBUG` mode can be enabled to see the cost breakdown printed at runtime.
90+
91+
The three pure-HE strategies (M1, M2, M3) differ in how they pack matrix elements into CKKS ciphertext slots — column-wise, row-wise, or diagonal-wise. Each has different rotation and multiplication costs depending on the matrix dimensions. The "Via MPC" path does the full E2S → Beaver matmul → S2E round-trip.
92+
93+
### Going deeper
94+
95+
The M1/M2/M3 implementations call `mpc.mhe.iadd`, `mpc.mhe.imul`, `mpc.mhe.irotate` — working directly with ciphertext-level operations. These are the Layer 2 (MPCEnv/MHE) primitives documented in [Core MHE Module](../deep-dive-shechi/core-mhe.md). Below that, each of those methods calls into Lattiseq's `Evaluator` for the actual CKKS polynomial arithmetic.
96+
97+
## When to drop a layer
98+
99+
Most protocol code should stay at the top level. Here's a rough guide for when it makes sense to go deeper:
100+
101+
**Stay at `@sequre` / tensor level** for algorithm logic — this is where all the built-in secure functions (`inv`, `sqrt`, `maximum`, etc.) and type-generic patterns live. See [One Algorithm, Many Secure Types](one-algorithm-many-secure-types.md).
102+
103+
**Use `via_mpc` explicitly** when you have a non-linear or comparison-heavy step inside an otherwise HE-based pipeline. PCA's eigendecomposition is the canonical example. See [MPC ↔ MHE Switching](../user-guide/switching.md).
104+
105+
**Drop into Ciphertensor / MHE** when encoding strategy needs to be controlled, ciphertext levels manually managed, or the matmul cost model tuned. See [Ciphertensor Internals](../deep-dive-shechi/ciphertensor-internals.md) and [Core MHE Module](../deep-dive-shechi/core-mhe.md).
106+
107+
**Use Lattiseq directly** only when extending the cryptographic protocols themselves — new collective operations, custom refresh logic, or low-level debugging of noise/precision issues. See [Lattiseq Overview](../deep-dive-lattiseq/overview.md) and [Lattiseq API](../api/lattiseq.md).
108+
109+
## Next steps
110+
111+
- [One Algorithm, Many Secure Types](one-algorithm-many-secure-types.md) — How the same code runs on ndarray, Sharetensor, and MPU.
112+
- [Transitioning to MHE](transition-mhe.md) — When to use Shechi's encrypted types.
113+
- [Distributed Tensors (MPU)](../user-guide/distributed-tensors.md) — MPU/MPP/MPA reference.
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
# One Algorithm, Many Secure Types
2+
3+
One of the nicest things about Sequre is that an algorithm can be implemented once and then run on plaintext ndarrays, on secret-shared Sharetensors, and on encrypted multiparty types (MPU/MPP) — without changing the algorithm code. This is extremely useful in practice: prototyping and debugging happens on plaintext first, then flipping the data type to a secure one reveals whether the outputs match.
4+
5+
This page walks through how that works, using linear regression and PCA as concrete examples.
6+
7+
## Linear regression: one class, any type
8+
9+
Open [stdlib/sequre/stdlib/learn/lin_reg.codon](../../stdlib/sequre/stdlib/learn/lin_reg.codon). The class is declared as `LinReg[T]` — it's generic over the tensor type `T`:
10+
11+
```python
12+
class LinReg[T]:
13+
coef_: T
14+
optimizer: str
15+
16+
def fit(self, mpc, X: T, y: T, step: float, epochs: int, ...) -> LinReg[T]:
17+
self.coef_ = LinReg._fit(mpc, X, y, self.coef_, ...)
18+
return self
19+
20+
def predict(self, mpc, X: T, noise_scale: float = 0.0) -> T:
21+
return LinReg._predict(mpc, X, self.coef_, noise_scale)
22+
```
23+
24+
The interesting part is what `_fit` actually does. Look at the batch gradient descent inner loop:
25+
26+
```python
27+
@sequre
28+
def _bgd(mpc, X_tilde: T, y: T, initial_w: T, step: float, epochs: int, ...) -> T:
29+
# Pre-compute invariants
30+
cov = X_tilde.T @ X_tilde # n x n
31+
ref = X_tilde.T @ y # n x 1
32+
33+
w = initial_w
34+
for _ in range(epochs):
35+
w += (ref - cov @ w) * step
36+
37+
return w
38+
```
39+
40+
There's nothing type-specific in this code. `X_tilde.T @ X_tilde` is just matrix multiplication — but what happens underneath depends entirely on what `T` is. If `T` is `ndarray`, it's ordinary NumPy-style arithmetic. If `T` is `Sharetensor`, the `@sequre` decorator rewrites `@` into Beaver-triple secure multiplication. If `T` is `MPU`, the framework picks between HE-based matmul strategies or switches to MPC via `via_mpc`, depending on estimated cost.
41+
42+
The closed-form solver shows the same pattern with `inv`:
43+
44+
```python
45+
@sequre
46+
def _closed_form(mpc, X: T, y: T) -> T:
47+
return inv(mpc, X.T @ X) @ X.T @ y
48+
```
49+
50+
`inv` also dispatches by type — for `Sharetensor` or `ndarray` it does direct matrix inversion formula; for encrypted types it calls `x.via_mpc(lambda stensor: inv(mpc, stensor))` to switch to MPC, compute the inverse there, and switch back. This can be seen in [stdlib/sequre/stdlib/builtin.codon](../../stdlib/sequre/stdlib/builtin.codon).
51+
52+
### Where this is used for real
53+
54+
The Multiple Imputation application ([applications/mi.codon](../../applications/mi.codon)) uses `LinReg[T]` and `LogReg[T]` over parameterized secure types — same algorithm, different backends depending on the deployment scenario.
55+
56+
## PCA: same algorithm on four data types
57+
58+
The PCA test in [tests/e2e_tests/test_pca.codon](../../tests/e2e_tests/test_pca.codon) is the clearest side-by-side comparison of running the same computation across representations. Here's what happens:
59+
60+
**Step 1: Run on plaintext.** The test calls `random_pca_with_norm(mpc, raw_data, ...)` where `raw_data` is just an ndarray. This gives a plaintext reference result.
61+
62+
**Step 2: Run on Sharetensor.** Same call, but now the data is secret-shared:
63+
64+
```python
65+
mpc_data = Sharetensor.enc(mpc, raw_data, 0, modulus)
66+
# ... (encode all inputs as Sharetensors)
67+
68+
mpc_pca_u, mpc_pca_z = random_pca_with_norm(
69+
mpc, mpc_data, mpc_miss, mpc_data_mean, mpc_data_std_inv, ...)
70+
```
71+
72+
Then the test reveals the MPC result and asserts approximate equality with the plaintext version:
73+
74+
```python
75+
assert_eq_approx("Sequre std PCA U (MPC)", mpc_pca_u.reveal(mpc), classic_pca_u)
76+
```
77+
78+
**Step 3: Run on MPP and MPU.** The same data is loaded into partitioned/encrypted forms:
79+
80+
```python
81+
mpp_data = MPP(mpc, ... raw_cent_data[(mpc.pid - 1) * rows_per_party:mpc.pid * rows_per_party])
82+
mpu_data = MPU(mpc, mpp_data._local_data, "partition")
83+
```
84+
85+
And PCA runs again, this time using `random_pca_without_projection` which internally does HE-backed matrix multiplications and calls `.via_mpc(...)` when it hits operations that need MPC (like orthonormalization and eigendecomposition). The result is again checked against the plaintext reference.
86+
87+
This is the recommended workflow for building a new protocol:
88+
89+
1. Get the math right on ndarray.
90+
2. Switch to `Sharetensor` and check that the secure version matches.
91+
3. Move to `MPU`/`MPP` for the distributed/encrypted-scale execution.
92+
4. Fix any numerical drift (CKKS is approximate, so you may need to tune tolerances).
93+
94+
## How the dispatch works
95+
96+
There is no need to write separate implementations for each type. The `@sequre` decorator and Codon's operator overloading handle the dispatch:
97+
98+
- On `ndarray`: operators are plain arithmetic.
99+
- On `Sharetensor`: `+` is local, `*` and `@` use Beaver triples (communication round).
100+
- On `MPU`/`MPP`/`MPA`: operators route to the underlying `Ciphertensor` HE operations or switch to MPC via `via_mpc` when needed.
101+
102+
## Next steps
103+
104+
- [Transitioning to MHE](transition-mhe.md) — When and why to use Shechi's encrypted types instead of Sharetensor.
105+
- [Dropping Down the Stack](dropping-down-the-stack.md) — What happens below `@sequre` and `via_mpc`.
106+
- [Distributed Tensors (MPU)](../user-guide/distributed-tensors.md) — Detailed reference for MPU/MPP/MPA.
107+
- [MPC ↔ MHE Protocol Switching](../user-guide/switching.md) — How `via_mpc` works under the hood.
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# Secure Branching Without `if`
2+
3+
Sequre deliberately disallows branching (`if`) on secret data. I.e. the following will not work whenever `x` or `y` are encrypted.
4+
5+
```python
6+
if x > y:
7+
z = x
8+
else:
9+
z = y
10+
```
11+
12+
Otherwise, both branches would need to be evaluated not to leak whether `x > y`.
13+
14+
To implement `max`, `min`, `clip`, or any other conditional logic on secrets, the branching is replaced with arithmetic.
15+
16+
## How it works
17+
18+
The idea is simple: compute the comparison as a secret-shared 0-or-1 value, then use it as an arithmetic mask to select between two outcomes — without ever branching.
19+
20+
Here is the actual `maximum` from Sequre's stdlib ([stdlib/sequre/stdlib/builtin.codon](../../stdlib/sequre/stdlib/builtin.codon)):
21+
22+
```python
23+
@sequre
24+
def maximum(mpc, x, y):
25+
mask = ((x - y) > 0).astype(float)
26+
return x * mask - y * (mask - 1)
27+
```
28+
29+
Walk through it: `(x - y) > 0` produces a secret-shared bit — `1` if `x > y`, `0` otherwise. Casting it to float gives a mask that is either `1.0` or `0.0`. Then:
30+
31+
- If `mask = 1`: result is `x * 1 - y * 0 = x`. Correct — `x` was bigger.
32+
- If `mask = 0`: result is `x * 0 - y * (-1) = y`. Correct — `y` was bigger.
33+
34+
No branch taken, no information leaked. Both multiplication paths are always executed.
35+
36+
`minimum` is similar:
37+
38+
```python
39+
@sequre
40+
def minimum(mpc, x, y):
41+
mask = ((y - x) > 0).astype(float)
42+
return x * mask - y * (mask - 1)
43+
```
44+
45+
## More examples from the codebase
46+
47+
### Clipping
48+
49+
`clip` needs to handle two boundaries at once — a low threshold and a high threshold. It builds two masks and combines them:
50+
51+
```python
52+
@sequre
53+
def clip(mpc, x, low, high):
54+
low_mask = (x < low).astype(float)
55+
high_mask = (x > high).astype(float)
56+
return x * (1 - (low_mask + high_mask)) + low_mask * low + high_mask * high
57+
```
58+
59+
If `x` is below `low`, the `low_mask` fires and the result is `low`. If it's above `high`, the `high_mask` fires. Otherwise both masks are zero and you get `x` unchanged.
60+
61+
### Absolute value
62+
63+
```python
64+
@sequre
65+
def abs(mpc, x):
66+
return x * (((x > 0) * 2) - 1)
67+
```
68+
69+
This computes `sign(x)` as `+1` or `-1` and multiplies. If `x > 0`, the factor is `(1*2) - 1 = 1`. If `x <= 0`, it's `(0*2) - 1 = -1`.
70+
71+
### Argmax
72+
73+
`argmax` is more involved — it walks through a vector, keeping a running maximum and the index that produced it. At each step it uses `max` to do a branchless comparison and update:
74+
75+
```python
76+
@sequre
77+
def argmax(mpc, x):
78+
arg, maximum = Sharetensor(0, x.modulus), x[0]
79+
80+
for i in range(1, len(x)):
81+
new_maximum = max(mpc, maximum, x[i])
82+
arg = max(mpc, arg, (new_maximum > maximum) * i)
83+
maximum = new_maximum
84+
85+
return arg, maximum
86+
```
87+
88+
Notice `(new_maximum > maximum) * i` — this produces `i` if the new element was bigger, or `0` otherwise. Then `max(mpc, arg, ...)` picks the larger of the current argmax and this candidate. All branchless.
89+
90+
## What to keep in mind
91+
92+
**Both sides always execute.** Unlike a plaintext `if`, the expensive branch cannot be skipped. If one path involves a heavy computation, the cost is paid regardless of the condition.
93+
94+
**Comparisons are not cheap.** Under the hood, `x > y` on secret-shared data involves bit decomposition — significantly more expensive than addition or multiplication. If an algorithm does many comparisons, that will dominate the cost. Restructuring to minimize the number of comparison operations is advisable.
95+
96+
**There's also oblivious array access.** When indexing into an array at a secret position (not just pick between two values), Sequre provides `oblivious_get` in [stdlib/sequre/mpc/collections.codon](../../stdlib/sequre/mpc/collections.codon). It uses a demultiplexer built from bit decomposition ([stdlib/sequre/mpc/boolean.codon](../../stdlib/sequre/mpc/boolean.codon)) to read a public array at a secret index without revealing which element was accessed. This scales as $O(2^{\text{bits}})$, so best to keep the key bit length small.
97+
98+
## Existing built-ins
99+
100+
Before writing custom mask logic, check if Sequre already provides what is needed — `maximum`, `minimum`, `clip`, `abs`, `sign`, and `argmax` are all in `stdlib/sequre/stdlib/builtin.codon`.
101+
102+
## Next steps
103+
104+
- [Basic MPC Computation](basic-mpc.md) — How secure arithmetic and comparisons work underneath.
105+
- [Secure Stdlib API](../api/stdlib.md) — Full reference for the built-in secure functions.
106+
- [MPC ↔ MHE Protocol Switching](../user-guide/switching.md) — How comparisons on encrypted (MHE) data switch to MPC automatically.

docs/tutorials/transition-mhe.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@ Both passes are applied automatically to `@sequre`-annotated functions when the
113113

114114
## Next steps
115115

116+
- **[One Algorithm, Many Secure Types](one-algorithm-many-secure-types.md)** — Validate and debug the same protocol across plaintext and secure representations.
117+
- **[Dropping Down the Stack](dropping-down-the-stack.md)** — Learn when to stay high-level and when to descend into MHE/Lattiseq internals.
116118
- **[Distributed Tensors (MPU)](../user-guide/distributed-tensors.md)** — Deep dive into MPU semantics.
117119
- **[Ciphertensor API](../api/ciphertensor.md)** — Complete reference.
118120
- **[Core MHE module](../deep-dive-shechi/core-mhe.md)** — Internals of the collective protocols.

0 commit comments

Comments
 (0)